diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 64a020283..770bf38a1 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -221,97 +221,21 @@ jobs:
name: playwright-artifacts
path: playwright-artifacts
+ - name: Fetch gh-pages branch
+ run: |
+ git fetch origin gh-pages:gh-pages
+ mkdir gh-pages
+ git --work-tree=gh-pages checkout gh-pages -- .
+
- name: Update PR description
uses: actions/github-script@v6
+ env:
+ CURRENT_SIZE: ${{ needs.bundle_size.outputs.current_size }}
+ MAIN_SIZE: ${{ needs.bundle_size.outputs.main_size }}
+ SIZE_DIFF: ${{ needs.bundle_size.outputs.diff }}
+ SIZE_PERCENT: ${{ needs.bundle_size.outputs.percent }}
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
- const fs = require('fs');
- const testResultsPath = 'playwright-artifacts/test-results.json';
- let testResults;
-
- if (fs.existsSync(testResultsPath)) {
- const rawData = fs.readFileSync(testResultsPath);
- const data = JSON.parse(rawData);
- testResults = {
- total: data.stats.expected + data.stats.unexpected + data.stats.flaky + data.stats.skipped,
- passed: data.stats.expected,
- failed: data.stats.unexpected,
- flaky: data.stats.flaky,
- skipped: data.stats.skipped
- };
- } else {
- console.log('Test results file not found');
- testResults = { total: 0, passed: 0, failed: 0, flaky: 0, skipped: 0 };
- }
-
- const reportUrl = `https://${context.repo.owner}.github.io/${context.repo.repo}/${context.issue.number}/`;
- const status = testResults.failed > 0 ? '❌ FAILED' : (testResults.flaky > 0 ? '⚠️ FLAKY' : '✅ PASSED');
- const statusColor = testResults.failed > 0 ? 'red' : (testResults.flaky > 0 ? 'orange' : 'green');
-
- const currentSize = parseInt('${{ needs.bundle_size.outputs.current_size }}');
- const mainSize = parseInt('${{ needs.bundle_size.outputs.main_size }}');
- const diff = parseInt('${{ needs.bundle_size.outputs.diff }}');
- const percent = '${{ needs.bundle_size.outputs.percent }}';
-
- const formatSize = (size) => {
- if (size >= 1024) {
- return `${(size / (1024 * 1024)).toFixed(2)} MB`;
- }
- return `${(size / 1024).toFixed(2)} KB`;
- };
-
- const bundleStatus = percent === 'N/A' ? '⚠️' :
- parseFloat(percent) > 0 ? '🔺' :
- parseFloat(percent) < 0 ? '🔽' : '✅';
-
- const ciSection = `## CI Results
-
- ### Test Status: ${status}
- 📊 [Full Report](${reportUrl})
-
- | Total | Passed | Failed | Flaky | Skipped |
- |:-----:|:------:|:------:|:-----:|:-------:|
- | ${testResults.total} | ${testResults.passed} | ${testResults.failed} | ${testResults.flaky} | ${testResults.skipped} |
-
- ### Bundle Size: ${bundleStatus}
- Current: ${formatSize(currentSize)} | Main: ${formatSize(mainSize)}
- Diff: ${diff > 0 ? '+' : ''}${formatSize(Math.abs(diff))} (${percent === 'N/A' ? 'N/A' : `${percent}%`})
-
- ${
- percent === 'N/A' ? '⚠️ Unable to calculate change.' :
- parseFloat(percent) > 0 ? '⚠️ Bundle size increased. Please review.' :
- parseFloat(percent) < 0 ? '✅ Bundle size decreased.' : '✅ Bundle size unchanged.'
- }
-
-
- ℹ️ CI Information
-
- - Test recordings for failed tests are available in the full report.
- - Bundle size is measured for the entire 'dist' directory.
- - 📊 indicates links to detailed reports.
- - 🔺 indicates increase, 🔽 decrease, and ✅ no change in bundle size.
- `;
-
- const { data: pullRequest } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.issue.number,
- });
-
- const currentBody = pullRequest.body || '';
- const ciSectionRegex = /## CI Results[\s\S]*?(?=\n## (?!CI Results)|$)/;
-
- let newBody = currentBody;
- if (ciSectionRegex.test(newBody)) {
- newBody = newBody.replace(ciSectionRegex, ciSection);
- } else {
- newBody += '\n\n' + ciSection;
- }
-
- await github.rest.pulls.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.issue.number,
- body: newBody,
- });
+ const updatePRDescription = require('./.github/workflows/scripts/update-pr-description.js');
+ await updatePRDescription(github, context);
diff --git a/.github/workflows/scripts/__tests__/bundle.test.ts b/.github/workflows/scripts/__tests__/bundle.test.ts
new file mode 100644
index 000000000..2fef551a4
--- /dev/null
+++ b/.github/workflows/scripts/__tests__/bundle.test.ts
@@ -0,0 +1,108 @@
+import {generateBundleSizeSection, getBundleInfo} from '../utils/bundle';
+
+describe('bundle utils', () => {
+ describe('generateBundleSizeSection', () => {
+ it('should generate section for increased bundle size', () => {
+ const bundleInfo = {
+ currentSize: 1024 * 1024 * 2, // 2MB
+ mainSize: 1024 * 1024, // 1MB
+ diff: 1024 * 1024, // 1MB increase
+ percent: '100',
+ };
+
+ const result = generateBundleSizeSection(bundleInfo);
+ expect(result).toContain('Bundle Size: 🔺');
+ expect(result).toContain('Current: 2.00 MB | Main: 1.00 MB');
+ expect(result).toContain('Diff: +1.00 MB (100%)');
+ expect(result).toContain('⚠️ Bundle size increased. Please review.');
+ });
+
+ it('should generate section for decreased bundle size', () => {
+ const bundleInfo = {
+ currentSize: 1024 * 1024, // 1MB
+ mainSize: 1024 * 1024 * 2, // 2MB
+ diff: -1024 * 1024, // 1MB decrease
+ percent: '-50',
+ };
+
+ const result = generateBundleSizeSection(bundleInfo);
+ expect(result).toContain('Bundle Size: 🔽');
+ expect(result).toContain('Current: 1.00 MB | Main: 2.00 MB');
+ expect(result).toContain('Diff: 1.00 MB (-50%)');
+ expect(result).toContain('✅ Bundle size decreased.');
+ });
+
+ it('should generate section for unchanged bundle size', () => {
+ const bundleInfo = {
+ currentSize: 1024 * 1024, // 1MB
+ mainSize: 1024 * 1024, // 1MB
+ diff: 0,
+ percent: '0',
+ };
+
+ const result = generateBundleSizeSection(bundleInfo);
+ expect(result).toContain('Bundle Size: ✅');
+ expect(result).toContain('Current: 1.00 MB | Main: 1.00 MB');
+ expect(result).toContain('Diff: 0.00 KB (0%)');
+ expect(result).toContain('✅ Bundle size unchanged.');
+ });
+
+ it('should handle N/A percent', () => {
+ const bundleInfo = {
+ currentSize: 1024 * 1024, // 1MB
+ mainSize: 0,
+ diff: 1024 * 1024,
+ percent: 'N/A',
+ };
+
+ const result = generateBundleSizeSection(bundleInfo);
+ expect(result).toContain('Bundle Size: ⚠️');
+ expect(result).toContain('Current: 1.00 MB | Main: 0.00 KB');
+ expect(result).toContain('Diff: +1.00 MB (N/A)');
+ expect(result).toContain('⚠️ Unable to calculate change.');
+ });
+ });
+
+ describe('getBundleInfo', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = {...originalEnv};
+ });
+
+ afterAll(() => {
+ process.env = originalEnv;
+ });
+
+ it('should get bundle info from environment variables', () => {
+ process.env.CURRENT_SIZE = '2097152'; // 2MB
+ process.env.MAIN_SIZE = '1048576'; // 1MB
+ process.env.SIZE_DIFF = '1048576'; // 1MB
+ process.env.SIZE_PERCENT = '100';
+
+ const result = getBundleInfo();
+ expect(result).toEqual({
+ currentSize: 2097152,
+ mainSize: 1048576,
+ diff: 1048576,
+ percent: '100',
+ });
+ });
+
+ it('should handle missing environment variables', () => {
+ process.env.CURRENT_SIZE = undefined;
+ process.env.MAIN_SIZE = undefined;
+ process.env.SIZE_DIFF = undefined;
+ process.env.SIZE_PERCENT = undefined;
+
+ const result = getBundleInfo();
+ expect(result).toEqual({
+ currentSize: 0,
+ mainSize: 0,
+ diff: 0,
+ percent: 'N/A',
+ });
+ });
+ });
+});
diff --git a/.github/workflows/scripts/__tests__/format.test.ts b/.github/workflows/scripts/__tests__/format.test.ts
new file mode 100644
index 000000000..eec6949f5
--- /dev/null
+++ b/.github/workflows/scripts/__tests__/format.test.ts
@@ -0,0 +1,98 @@
+import {formatSize, generateTestChangesSummary} from '../utils/format';
+
+describe('format utils', () => {
+ describe('formatSize', () => {
+ it('should format size in KB when less than 1024 bytes', () => {
+ const size = 512; // 512 bytes
+ expect(formatSize(size)).toBe('0.50 KB');
+ });
+
+ it('should format size in MB when greater than or equal to 1024 bytes', () => {
+ const size = 2.5 * 1024; // 2.5 KB -> will be shown in MB
+ expect(formatSize(size)).toBe('2.50 KB');
+ });
+
+ it('should handle small sizes', () => {
+ const size = 100; // 100 bytes
+ expect(formatSize(size)).toBe('0.10 KB');
+ });
+
+ it('should handle zero', () => {
+ expect(formatSize(0)).toBe('0.00 KB');
+ });
+ });
+
+ describe('generateTestChangesSummary', () => {
+ it('should generate summary for new tests only', () => {
+ const comparison = {
+ new: ['Test 1 (file1.ts)', 'Test 2 (file2.ts)'],
+ skipped: [],
+ deleted: [],
+ };
+
+ const summary = generateTestChangesSummary(comparison);
+ expect(summary).toContain('✨ New Tests (2)');
+ expect(summary).toContain('1. Test 1 (file1.ts)');
+ expect(summary).toContain('2. Test 2 (file2.ts)');
+ expect(summary).not.toContain('⏭️ Skipped Tests');
+ expect(summary).not.toContain('🗑️ Deleted Tests');
+ });
+
+ it('should generate summary for skipped tests only', () => {
+ const comparison = {
+ new: [],
+ skipped: ['Test 1 (file1.ts)', 'Test 2 (file2.ts)'],
+ deleted: [],
+ };
+
+ const summary = generateTestChangesSummary(comparison);
+ expect(summary).toContain('⏭️ Skipped Tests (2)');
+ expect(summary).toContain('1. Test 1 (file1.ts)');
+ expect(summary).toContain('2. Test 2 (file2.ts)');
+ expect(summary).not.toContain('✨ New Tests');
+ expect(summary).not.toContain('🗑️ Deleted Tests');
+ });
+
+ it('should generate summary for deleted tests only', () => {
+ const comparison = {
+ new: [],
+ skipped: [],
+ deleted: ['Test 1 (file1.ts)', 'Test 2 (file2.ts)'],
+ };
+
+ const summary = generateTestChangesSummary(comparison);
+ expect(summary).toContain('🗑️ Deleted Tests (2)');
+ expect(summary).toContain('1. Test 1 (file1.ts)');
+ expect(summary).toContain('2. Test 2 (file2.ts)');
+ expect(summary).not.toContain('✨ New Tests');
+ expect(summary).not.toContain('⏭️ Skipped Tests');
+ });
+
+ it('should generate summary for all types of changes', () => {
+ const comparison = {
+ new: ['New Test (file1.ts)'],
+ skipped: ['Skipped Test (file2.ts)'],
+ deleted: ['Deleted Test (file3.ts)'],
+ };
+
+ const summary = generateTestChangesSummary(comparison);
+ expect(summary).toContain('✨ New Tests (1)');
+ expect(summary).toContain('⏭️ Skipped Tests (1)');
+ expect(summary).toContain('🗑️ Deleted Tests (1)');
+ expect(summary).toContain('New Test (file1.ts)');
+ expect(summary).toContain('Skipped Test (file2.ts)');
+ expect(summary).toContain('Deleted Test (file3.ts)');
+ });
+
+ it('should handle no changes', () => {
+ const comparison = {
+ new: [],
+ skipped: [],
+ deleted: [],
+ };
+
+ const summary = generateTestChangesSummary(comparison);
+ expect(summary).toBe('😟 No changes in tests. 😕');
+ });
+ });
+});
diff --git a/.github/workflows/scripts/__tests__/results.test.ts b/.github/workflows/scripts/__tests__/results.test.ts
new file mode 100644
index 000000000..0dc74793c
--- /dev/null
+++ b/.github/workflows/scripts/__tests__/results.test.ts
@@ -0,0 +1,135 @@
+import fs from 'fs';
+
+import {getTestStatus, readTestResults} from '../utils/results';
+import type {TestResults, TestResultsInfo, TestStatusInfo} from './types';
+
+jest.mock('fs');
+
+describe('results utils', () => {
+ describe('readTestResults', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should handle non-existent file', () => {
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
+
+ const result = readTestResults('nonexistent.json');
+ expect(result).toEqual({
+ total: 0,
+ passed: 0,
+ failed: 0,
+ flaky: 0,
+ skipped: 0,
+ tests: [],
+ });
+ });
+
+ it('should read and process test results correctly', () => {
+ const mockTestResults: TestResults = {
+ config: {} as any,
+ suites: [
+ {
+ title: 'Test Suite',
+ file: 'test.spec.ts',
+ column: 1,
+ line: 1,
+ specs: [
+ {
+ title: 'Test 1',
+ ok: true,
+ tags: [],
+ id: '1',
+ file: 'test.spec.ts',
+ line: 2,
+ column: 1,
+ tests: [
+ {
+ timeout: 5000,
+ annotations: [],
+ expectedStatus: 'passed',
+ projectId: '1',
+ projectName: 'test',
+ results: [],
+ status: 'passed',
+ },
+ ],
+ },
+ ],
+ suites: [],
+ },
+ ],
+ };
+
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
+ (fs.readFileSync as jest.Mock).mockReturnValue(
+ JSON.stringify({
+ ...mockTestResults,
+ stats: {
+ expected: 5,
+ unexpected: 2,
+ flaky: 1,
+ skipped: 3,
+ },
+ }),
+ );
+
+ const result = readTestResults('test-results.json');
+ expect(result).toEqual({
+ total: 11,
+ passed: 5,
+ failed: 2,
+ flaky: 1,
+ skipped: 3,
+ tests: expect.any(Array),
+ });
+ });
+ });
+
+ describe('getTestStatus', () => {
+ it('should return failed status when there are failures', () => {
+ const results: TestResultsInfo = {
+ total: 10,
+ passed: 8,
+ failed: 2,
+ flaky: 0,
+ skipped: 0,
+ tests: [],
+ };
+
+ const result = getTestStatus(results) as TestStatusInfo;
+ expect(result.status).toBe('❌ FAILED');
+ expect(result.statusColor).toBe('red');
+ });
+
+ it('should return flaky status when there are flaky tests but no failures', () => {
+ const results: TestResultsInfo = {
+ total: 10,
+ passed: 8,
+ failed: 0,
+ flaky: 2,
+ skipped: 0,
+ tests: [],
+ };
+
+ const result = getTestStatus(results) as TestStatusInfo;
+ expect(result.status).toBe('⚠️ FLAKY');
+ expect(result.statusColor).toBe('orange');
+ });
+
+ it('should return passed status when all tests pass', () => {
+ const results: TestResultsInfo = {
+ total: 10,
+ passed: 10,
+ failed: 0,
+ flaky: 0,
+ skipped: 0,
+ tests: [],
+ };
+
+ const result = getTestStatus(results) as TestStatusInfo;
+ expect(result.status).toBe('✅ PASSED');
+ expect(result.statusColor).toBe('green');
+ });
+ });
+});
diff --git a/.github/workflows/scripts/__tests__/test.test.ts b/.github/workflows/scripts/__tests__/test.test.ts
new file mode 100644
index 000000000..ecc0e888e
--- /dev/null
+++ b/.github/workflows/scripts/__tests__/test.test.ts
@@ -0,0 +1,222 @@
+import {compareTests, extractTestsFromSuite, isTestSkipped} from '../utils/test';
+import type {Spec, Suite, TestInfo} from './types';
+
+describe('test utils', () => {
+ describe('isTestSkipped', () => {
+ it('should return true for test with skip annotation', () => {
+ const spec: Spec = {
+ title: 'Test',
+ ok: true,
+ tags: [],
+ id: '1',
+ file: 'test.spec.ts',
+ line: 1,
+ column: 1,
+ tests: [
+ {
+ timeout: 5000,
+ annotations: [{type: 'skip'}],
+ expectedStatus: 'passed',
+ projectId: '1',
+ projectName: 'test',
+ results: [],
+ status: 'passed',
+ },
+ ],
+ };
+ expect(isTestSkipped(spec)).toBe(true);
+ });
+
+ it('should return true for test with skipped status', () => {
+ const spec: Spec = {
+ title: 'Test',
+ ok: true,
+ tags: [],
+ id: '1',
+ file: 'test.spec.ts',
+ line: 1,
+ column: 1,
+ tests: [
+ {
+ timeout: 5000,
+ annotations: [],
+ expectedStatus: 'skipped',
+ projectId: '1',
+ projectName: 'test',
+ results: [],
+ status: 'skipped',
+ },
+ ],
+ };
+ expect(isTestSkipped(spec)).toBe(true);
+ });
+
+ it('should return false for non-skipped test', () => {
+ const spec: Spec = {
+ title: 'Test',
+ ok: true,
+ tags: [],
+ id: '1',
+ file: 'test.spec.ts',
+ line: 1,
+ column: 1,
+ tests: [
+ {
+ timeout: 5000,
+ annotations: [],
+ expectedStatus: 'passed',
+ projectId: '1',
+ projectName: 'test',
+ results: [],
+ status: 'passed',
+ },
+ ],
+ };
+ expect(isTestSkipped(spec)).toBe(false);
+ });
+ });
+
+ describe('extractTestsFromSuite', () => {
+ it('should extract tests from a simple suite', () => {
+ const suite: Suite = {
+ title: 'Suite 1',
+ file: 'test.spec.ts',
+ column: 1,
+ line: 1,
+ specs: [
+ {
+ title: 'Test 1',
+ ok: true,
+ tags: [],
+ id: '1',
+ file: 'test.spec.ts',
+ line: 2,
+ column: 1,
+ tests: [
+ {
+ timeout: 5000,
+ annotations: [],
+ expectedStatus: 'passed',
+ projectId: '1',
+ projectName: 'test',
+ results: [],
+ status: 'passed',
+ },
+ ],
+ },
+ ],
+ suites: [],
+ };
+
+ const result = extractTestsFromSuite(suite);
+ expect(result).toEqual([
+ {
+ title: 'Test 1',
+ fullTitle: 'Suite 1 > Test 1',
+ status: 'passed',
+ file: 'test.spec.ts',
+ skipped: false,
+ },
+ ]);
+ });
+
+ it('should handle nested suites', () => {
+ const suite: Suite = {
+ title: 'Parent Suite',
+ file: 'test.spec.ts',
+ column: 1,
+ line: 1,
+ specs: [],
+ suites: [
+ {
+ title: 'Child Suite',
+ file: 'test.spec.ts',
+ column: 1,
+ line: 2,
+ specs: [
+ {
+ title: 'Test 1',
+ ok: true,
+ tags: [],
+ id: '1',
+ file: 'test.spec.ts',
+ line: 3,
+ column: 1,
+ tests: [
+ {
+ timeout: 5000,
+ annotations: [],
+ expectedStatus: 'passed',
+ projectId: '1',
+ projectName: 'test',
+ results: [],
+ status: 'passed',
+ },
+ ],
+ },
+ ],
+ suites: [],
+ },
+ ],
+ };
+
+ const result = extractTestsFromSuite(suite);
+ expect(result).toEqual([
+ {
+ title: 'Test 1',
+ fullTitle: 'Parent Suite > Child Suite > Test 1',
+ status: 'passed',
+ file: 'test.spec.ts',
+ skipped: false,
+ },
+ ]);
+ });
+ });
+
+ describe('compareTests', () => {
+ it('should identify new, skipped, and deleted tests', () => {
+ const currentTests: TestInfo[] = [
+ {
+ title: 'Test 1',
+ fullTitle: 'Suite > Test 1',
+ file: 'test.spec.ts',
+ status: 'passed',
+ skipped: false,
+ },
+ {
+ title: 'Test 2',
+ fullTitle: 'Suite > Test 2',
+ file: 'test.spec.ts',
+ status: 'skipped',
+ skipped: true,
+ },
+ ];
+
+ const mainTests: TestInfo[] = [
+ {
+ title: 'Test 3',
+ fullTitle: 'Suite > Test 3',
+ file: 'test.spec.ts',
+ status: 'passed',
+ skipped: false,
+ },
+ ];
+
+ const result = compareTests(currentTests, mainTests);
+ expect(result).toEqual({
+ new: ['Test 1 (test.spec.ts)', 'Test 2 (test.spec.ts)'],
+ skipped: ['Test 2 (test.spec.ts)'],
+ deleted: ['Test 3 (test.spec.ts)'],
+ });
+ });
+
+ it('should handle empty test arrays', () => {
+ const result = compareTests([], []);
+ expect(result).toEqual({
+ new: [],
+ skipped: [],
+ deleted: [],
+ });
+ });
+ });
+});
diff --git a/.github/workflows/scripts/__tests__/types.ts b/.github/workflows/scripts/__tests__/types.ts
new file mode 100644
index 000000000..f88c42ced
--- /dev/null
+++ b/.github/workflows/scripts/__tests__/types.ts
@@ -0,0 +1,127 @@
+export interface TestResults {
+ config: Config;
+ suites: Suite[];
+}
+
+export interface Config {
+ configFile: string;
+ rootDir: string;
+ forbidOnly: boolean;
+ fullyParallel: boolean;
+ globalSetup: string;
+ globalTeardown: null;
+ globalTimeout: number;
+ grep: Record;
+ grepInvert: null;
+ maxFailures: number;
+ metadata: Metadata;
+ preserveOutput: string;
+ reporter: Reporter[];
+ reportSlowTests: ReportSlowTests;
+ quiet: boolean;
+ projects: Project[];
+ shard: null;
+ updateSnapshots: string;
+ version: string;
+ workers: number;
+ webServer: WebServer;
+}
+
+export interface Metadata {
+ actualWorkers: number;
+}
+
+export interface Reporter {
+ [index: number]: string | ReporterConfig;
+}
+
+export interface ReporterConfig {
+ outputFolder?: string;
+ outputFile?: string;
+}
+
+export interface ReportSlowTests {
+ max: number;
+ threshold: number;
+}
+
+export interface Project {
+ outputDir: string;
+ repeatEach: number;
+ retries: number;
+ id: string;
+ name: string;
+ testDir: string;
+ testIgnore: unknown[];
+ testMatch: string[];
+ timeout: number;
+}
+
+export interface WebServer {
+ command: string;
+ port: number;
+}
+
+export interface Suite {
+ title: string;
+ file: string;
+ column: number;
+ line: number;
+ specs: Spec[];
+ suites: Suite[];
+}
+
+export interface Spec {
+ title: string;
+ ok: boolean;
+ tags: unknown[];
+ tests: Test[];
+ id: string;
+ file: string;
+ line: number;
+ column: number;
+}
+
+export interface Test {
+ timeout: number;
+ annotations: unknown[];
+ expectedStatus: string;
+ projectId: string;
+ projectName: string;
+ results: Result[];
+ status: string;
+}
+
+export interface Result {
+ workerIndex: number;
+ status: string;
+ duration: number;
+ errors: unknown[];
+ stdout: unknown[];
+ stderr: unknown[];
+ retry: number;
+ startTime: string;
+ attachments: unknown[];
+}
+
+export interface TestInfo {
+ title: string;
+ fullTitle: string;
+ status: string;
+ file: string;
+ skipped: boolean;
+}
+
+export interface TestResultsInfo {
+ total: number;
+ passed: number;
+ failed: number;
+ flaky: number;
+ skipped: number;
+ tests: TestInfo[];
+}
+
+export interface TestStatusInfo {
+ status: string;
+ statusColor: string;
+}
diff --git a/.github/workflows/scripts/__tests__/update-pr-description.test.ts b/.github/workflows/scripts/__tests__/update-pr-description.test.ts
new file mode 100644
index 000000000..ee9251f1d
--- /dev/null
+++ b/.github/workflows/scripts/__tests__/update-pr-description.test.ts
@@ -0,0 +1,249 @@
+import updatePRDescription from '../update-pr-description';
+import {generateBundleSizeSection, getBundleInfo} from '../utils/bundle';
+import {generateTestChangesSummary} from '../utils/format';
+import {getTestStatus, readTestResults} from '../utils/results';
+import {compareTests} from '../utils/test';
+import type {TestResultsInfo} from './types';
+
+// Mock dependencies
+jest.mock('../utils/results', () => ({
+ readTestResults: jest.fn(),
+ getTestStatus: jest.fn(),
+}));
+jest.mock('../utils/test');
+jest.mock('../utils/format');
+jest.mock('../utils/bundle');
+jest.mock('fs');
+
+describe('updatePRDescription', () => {
+ let mockGithub: {
+ rest: {
+ pulls: {
+ get: jest.Mock;
+ update: jest.Mock;
+ };
+ };
+ };
+ let mockContext: {
+ repo: {
+ owner: string;
+ repo: string;
+ };
+ issue: {
+ number: number;
+ };
+ };
+
+ beforeEach(() => {
+ // Reset all mocks
+ jest.clearAllMocks();
+
+ // Mock GitHub API
+ mockGithub = {
+ rest: {
+ pulls: {
+ get: jest.fn(),
+ update: jest.fn(),
+ },
+ },
+ };
+
+ // Mock GitHub context
+ mockContext = {
+ repo: {
+ owner: 'testOwner',
+ repo: 'testRepo',
+ },
+ issue: {
+ number: 123,
+ },
+ };
+
+ // Mock test results
+ const mockTestResults: TestResultsInfo = {
+ total: 10,
+ passed: 8,
+ failed: 1,
+ flaky: 0,
+ skipped: 1,
+ tests: [
+ {
+ title: 'Test 1',
+ fullTitle: 'Suite > Test 1',
+ status: 'passed',
+ file: 'test.spec.ts',
+ skipped: false,
+ },
+ ],
+ };
+
+ (readTestResults as jest.Mock).mockImplementation(() => mockTestResults);
+ (getTestStatus as jest.Mock).mockReturnValue({
+ status: '✅ PASSED',
+ statusColor: 'green',
+ });
+
+ // Mock test comparison
+ (compareTests as jest.Mock).mockReturnValue({
+ new: ['New Test (test.spec.ts)'],
+ skipped: ['Skipped Test (test.spec.ts)'],
+ deleted: ['Deleted Test (test.spec.ts)'],
+ });
+
+ // Mock summary generation
+ (generateTestChangesSummary as jest.Mock).mockReturnValue('Test Changes Summary');
+
+ // Mock bundle info
+ (getBundleInfo as jest.Mock).mockReturnValue({
+ currentSize: 1024,
+ mainSize: 1000,
+ diff: 24,
+ percent: '2.4',
+ });
+
+ (generateBundleSizeSection as jest.Mock).mockReturnValue('Bundle Size Section');
+
+ // Mock PR data
+ mockGithub.rest.pulls.get.mockResolvedValue({
+ data: {
+ body: 'Original PR description',
+ },
+ });
+
+ mockGithub.rest.pulls.update.mockResolvedValue({});
+ });
+
+ it('should read both current and main test results', async () => {
+ await updatePRDescription(mockGithub, mockContext);
+
+ expect(readTestResults).toHaveBeenCalledTimes(2);
+ expect(readTestResults).toHaveBeenCalledWith('playwright-artifacts/test-results.json');
+ expect(readTestResults).toHaveBeenCalledWith('gh-pages/main/test-results.json');
+ });
+
+ it('should format CI section with correct table and details', async () => {
+ const mockResults: TestResultsInfo = {
+ total: 5,
+ passed: 3,
+ failed: 1,
+ flaky: 0,
+ skipped: 1,
+ tests: [],
+ };
+ (readTestResults as jest.Mock).mockReturnValue(mockResults);
+ (getTestStatus as jest.Mock).mockReturnValue({
+ status: '❌ FAILED',
+ statusColor: 'red',
+ });
+
+ await updatePRDescription(mockGithub, mockContext);
+
+ const updateCall = mockGithub.rest.pulls.update.mock.calls[0][0];
+ const body = updateCall.body;
+
+ // Check table format
+ expect(body).toContain('| Total | Passed | Failed | Flaky | Skipped |');
+ expect(body).toContain('|:-----:|:------:|:------:|:-----:|:-------:|');
+ expect(body).toContain('| 5 | 3 | 1 | 0 | 1 |');
+
+ // Check details section
+ expect(body).toContain('');
+ expect(body).toContain('ℹ️ CI Information
');
+ expect(body).toContain('Test recordings for failed tests are available');
+ expect(body).toContain('Bundle size is measured');
+ expect(body).toContain(' ');
+ });
+
+ it('should handle PR without existing description', async () => {
+ mockGithub.rest.pulls.get.mockResolvedValue({
+ data: {
+ body: null,
+ },
+ });
+
+ await updatePRDescription(mockGithub, mockContext);
+
+ expect(mockGithub.rest.pulls.update).toHaveBeenCalled();
+ const updateCall = mockGithub.rest.pulls.update.mock.calls[0][0];
+ expect(updateCall.body).toContain('## CI Results');
+ });
+
+ it('should handle errors in test results', async () => {
+ const emptyResults: TestResultsInfo = {
+ total: 0,
+ passed: 0,
+ failed: 0,
+ flaky: 0,
+ skipped: 0,
+ tests: [],
+ };
+
+ (readTestResults as jest.Mock).mockReturnValue(emptyResults);
+ (getTestStatus as jest.Mock).mockReturnValue({
+ status: '✅ PASSED',
+ statusColor: 'green',
+ });
+
+ await updatePRDescription(mockGithub, mockContext);
+
+ expect(mockGithub.rest.pulls.update).toHaveBeenCalled();
+ const updateCall = mockGithub.rest.pulls.update.mock.calls[0][0];
+ expect(updateCall.body).toContain('## CI Results');
+ expect(updateCall.body).toContain('| 0 | 0 | 0 | 0 | 0 |');
+ });
+
+ it('should include report URL in description', async () => {
+ await updatePRDescription(mockGithub, mockContext);
+
+ expect(mockGithub.rest.pulls.update).toHaveBeenCalled();
+ const updateCall = mockGithub.rest.pulls.update.mock.calls[0][0];
+ const expectedUrl = `https://testOwner.github.io/testRepo/123/`;
+ expect(updateCall.body).toContain(expectedUrl);
+ });
+
+ it('should handle failed tests status color', async () => {
+ const failedResults: TestResultsInfo = {
+ total: 10,
+ passed: 8,
+ failed: 2,
+ flaky: 0,
+ skipped: 0,
+ tests: [],
+ };
+
+ (readTestResults as jest.Mock).mockReturnValue(failedResults);
+ (getTestStatus as jest.Mock).mockReturnValue({
+ status: '❌ FAILED',
+ statusColor: 'red',
+ });
+
+ await updatePRDescription(mockGithub, mockContext);
+
+ expect(mockGithub.rest.pulls.update).toHaveBeenCalled();
+ const updateCall = mockGithub.rest.pulls.update.mock.calls[0][0];
+ expect(updateCall.body).toContain('color: red');
+ });
+
+ it('should handle flaky tests status color', async () => {
+ const flakyResults: TestResultsInfo = {
+ total: 10,
+ passed: 8,
+ failed: 0,
+ flaky: 2,
+ skipped: 0,
+ tests: [],
+ };
+
+ (readTestResults as jest.Mock).mockReturnValue(flakyResults);
+ (getTestStatus as jest.Mock).mockReturnValue({
+ status: '⚠️ FLAKY',
+ statusColor: 'orange',
+ });
+
+ await updatePRDescription(mockGithub, mockContext);
+
+ expect(mockGithub.rest.pulls.update).toHaveBeenCalled();
+ const updateCall = mockGithub.rest.pulls.update.mock.calls[0][0];
+ expect(updateCall.body).toContain('color: orange');
+ });
+});
diff --git a/.github/workflows/scripts/update-pr-description.js b/.github/workflows/scripts/update-pr-description.js
new file mode 100644
index 000000000..00ae8f3da
--- /dev/null
+++ b/.github/workflows/scripts/update-pr-description.js
@@ -0,0 +1,71 @@
+const { compareTests } = require('./utils/test');
+const { generateTestChangesSummary } = require('./utils/format');
+const { generateBundleSizeSection, getBundleInfo } = require('./utils/bundle');
+const { readTestResults, getTestStatus } = require('./utils/results');
+
+/**
+ * Main function to update PR description with test results and bundle size information
+ * @param {Object} github - GitHub API object
+ * @param {Object} context - GitHub Actions context
+ */
+async function updatePRDescription(github, context) {
+ // Read test results
+ const currentResults = readTestResults('playwright-artifacts/test-results.json');
+ const mainResults = readTestResults('gh-pages/main/test-results.json');
+
+ // Compare tests
+ const testComparison = compareTests(currentResults.tests, mainResults.tests);
+
+ // Get test status and report URL
+ const { status, statusColor } = getTestStatus(currentResults);
+ const reportUrl = `https://${context.repo.owner}.github.io/${context.repo.repo}/${context.issue.number}/`;
+
+ // Get bundle size information
+ const bundleInfo = getBundleInfo();
+
+ // Generate the CI section content
+ const ciSection = `## CI Results
+
+ ### Test Status: ${status}
+ 📊 [Full Report](${reportUrl})
+
+ | Total | Passed | Failed | Flaky | Skipped |
+ |:-----:|:------:|:------:|:-----:|:-------:|
+ | ${currentResults.total} | ${currentResults.passed} | ${currentResults.failed} | ${currentResults.flaky} | ${currentResults.skipped} |
+
+ ${generateTestChangesSummary(testComparison)}
+
+ ${generateBundleSizeSection(bundleInfo)}
+
+
+ ℹ️ CI Information
+
+ - Test recordings for failed tests are available in the full report.
+ - Bundle size is measured for the entire 'dist' directory.
+ - 📊 indicates links to detailed reports.
+ - 🔺 indicates increase, 🔽 decrease, and ✅ no change in bundle size.
+ `;
+
+ // Update PR description
+ const { data: pullRequest } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number,
+ });
+
+ const currentBody = pullRequest.body || '';
+ const ciSectionRegex = /## CI Results[\s\S]*?(?=\n## (?!CI Results)|$)/;
+
+ const newBody = ciSectionRegex.test(currentBody)
+ ? currentBody.replace(ciSectionRegex, ciSection)
+ : currentBody + '\n\n' + ciSection;
+
+ await github.rest.pulls.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number,
+ body: newBody,
+ });
+}
+
+module.exports = updatePRDescription;
diff --git a/.github/workflows/scripts/utils/bundle.js b/.github/workflows/scripts/utils/bundle.js
new file mode 100644
index 000000000..c1c8be44c
--- /dev/null
+++ b/.github/workflows/scripts/utils/bundle.js
@@ -0,0 +1,40 @@
+const { formatSize } = require('./format');
+
+/**
+ * Generates the bundle size status section
+ * @param {Object} bundleInfo - Bundle size information
+ * @returns {string} Formatted bundle size section
+ */
+function generateBundleSizeSection({ currentSize, mainSize, diff, percent }) {
+ const bundleStatus = percent === 'N/A' ? '⚠️' :
+ parseFloat(percent) > 0 ? '🔺' :
+ parseFloat(percent) < 0 ? '🔽' : '✅';
+
+ const sizeChangeMessage = percent === 'N/A' ? '⚠️ Unable to calculate change.' :
+ parseFloat(percent) > 0 ? '⚠️ Bundle size increased. Please review.' :
+ parseFloat(percent) < 0 ? '✅ Bundle size decreased.' : '✅ Bundle size unchanged.';
+
+ return `### Bundle Size: ${bundleStatus}
+ Current: ${formatSize(currentSize)} | Main: ${formatSize(mainSize)}
+ Diff: ${diff > 0 ? '+' : ''}${formatSize(Math.abs(diff))} (${percent === 'N/A' ? 'N/A' : `${percent}%`})
+
+ ${sizeChangeMessage}`;
+}
+
+/**
+ * Gets bundle size information from environment variables
+ * @returns {Object} Bundle size information
+ */
+function getBundleInfo() {
+ return {
+ currentSize: parseInt(process.env.CURRENT_SIZE || '0'),
+ mainSize: parseInt(process.env.MAIN_SIZE || '0'),
+ diff: parseInt(process.env.SIZE_DIFF || '0'),
+ percent: process.env.SIZE_PERCENT || 'N/A'
+ };
+}
+
+module.exports = {
+ generateBundleSizeSection,
+ getBundleInfo
+};
diff --git a/.github/workflows/scripts/utils/format.js b/.github/workflows/scripts/utils/format.js
new file mode 100644
index 000000000..ed1f6d0f0
--- /dev/null
+++ b/.github/workflows/scripts/utils/format.js
@@ -0,0 +1,50 @@
+/**
+ * Formats a size in bytes to a human-readable string (KB or MB)
+ * @param {number} sizeInBytes - Size in bytes to format
+ * @returns {string} Formatted size string with units
+ */
+function formatSize(sizeInBytes) {
+ const MB_THRESHOLD = 10 * 1024;
+ if (sizeInBytes >= MB_THRESHOLD) {
+ return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
+ }
+ return `${(sizeInBytes / 1024).toFixed(2)} KB`;
+}
+
+/**
+ * Generates a summary of test changes
+ * @param {Object} comparison - Test comparison results
+ * @returns {string} Formatted test changes summary
+ */
+function generateTestChangesSummary(comparison) {
+ if (!comparison.new.length && !comparison.deleted.length && !comparison.skipped.length) {
+ return '😟 No changes in tests. 😕';
+ }
+
+ const summaryParts = [];
+ const { new: newTests, skipped, deleted } = comparison;
+
+ if (newTests.length) {
+ summaryParts.push(`#### ✨ New Tests (${newTests.length})\n${newTests.map((test, i) => `${i + 1}. ${test}`).join('\n')}\n`);
+ }
+
+ if (skipped.length) {
+ summaryParts.push(`#### ⏭️ Skipped Tests (${skipped.length})\n${skipped.map((test, i) => `${i + 1}. ${test}`).join('\n')}\n`);
+ }
+
+ if (deleted.length) {
+ summaryParts.push(`#### 🗑️ Deleted Tests (${deleted.length})\n${deleted.map((test, i) => `${i + 1}. ${test}`).join('\n')}`);
+ }
+
+ return `
+
+ Test Changes Summary ${newTests.length ? `✨${newTests.length} ` : ''}${skipped.length ? `⏭️${skipped.length} ` : ''}${deleted.length ? `🗑️${deleted.length}` : ''}
+
+ ${summaryParts.join('\n')}
+ `;
+}
+
+module.exports = {
+ formatSize,
+ generateTestChangesSummary
+};
diff --git a/.github/workflows/scripts/utils/results.js b/.github/workflows/scripts/utils/results.js
new file mode 100644
index 000000000..89cd2cac0
--- /dev/null
+++ b/.github/workflows/scripts/utils/results.js
@@ -0,0 +1,48 @@
+const fs = require('fs');
+const { extractTestsFromSuite } = require('./test');
+
+/**
+ * Reads and processes test results from a JSON file
+ * @param {string} filePath - Path to the test results JSON file
+ * @returns {Object} Processed test results
+ */
+function readTestResults(filePath) {
+ if (!fs.existsSync(filePath)) {
+ console.log(`Test results file not found: ${filePath}`);
+ return { total: 0, passed: 0, failed: 0, flaky: 0, skipped: 0, tests: [] };
+ }
+
+ const data = JSON.parse(fs.readFileSync(filePath));
+ const allTests = data.suites.flatMap(suite => extractTestsFromSuite(suite));
+
+ return {
+ total: data.stats.expected + data.stats.unexpected + data.stats.flaky + data.stats.skipped,
+ passed: data.stats.expected,
+ failed: data.stats.unexpected,
+ flaky: data.stats.flaky,
+ skipped: data.stats.skipped,
+ tests: allTests
+ };
+}
+
+/**
+ * Gets the test status information
+ * @param {Object} results - Test results object
+ * @returns {Object} Status information including color and label
+ */
+function getTestStatus(results) {
+ const status = results.failed > 0 ? '❌ FAILED' :
+ results.flaky > 0 ? '⚠️ FLAKY' :
+ '✅ PASSED';
+
+ const statusColor = results.failed > 0 ? 'red' :
+ results.flaky > 0 ? 'orange' :
+ 'green';
+
+ return { status, statusColor };
+}
+
+module.exports = {
+ readTestResults,
+ getTestStatus
+};
diff --git a/.github/workflows/scripts/utils/test.js b/.github/workflows/scripts/utils/test.js
new file mode 100644
index 000000000..aad4d6d6e
--- /dev/null
+++ b/.github/workflows/scripts/utils/test.js
@@ -0,0 +1,86 @@
+/**
+ * Checks if a test spec is marked as skipped
+ * @param {Object} spec - Test specification object
+ * @returns {boolean} True if the test is skipped
+ */
+function isTestSkipped(spec) {
+ return spec.tests?.[0] && (
+ spec.tests[0].annotations?.some(a => a.type === 'skip') ||
+ spec.tests[0].status === 'skipped'
+ );
+}
+
+/**
+ * Extracts test information from a test suite recursively
+ * @param {Object} suite - Test suite object
+ * @param {string} parentTitle - Parent suite title for nested suites
+ * @returns {Array} Array of test objects with metadata
+ */
+function extractTestsFromSuite(suite, parentTitle = '') {
+ const tests = [];
+ const fullSuiteTitle = parentTitle ? `${parentTitle} > ${suite.title}` : suite.title;
+
+ // Process individual test specs
+ if (suite.specs) {
+ const suiteTests = suite.specs.map(spec => {
+ const isSkipped = isTestSkipped(spec);
+ return {
+ title: spec.title,
+ fullTitle: `${fullSuiteTitle} > ${spec.title}`,
+ status: isSkipped ? 'skipped' : (spec.ok ? 'passed' : 'failed'),
+ file: suite.file,
+ skipped: isSkipped
+ };
+ });
+ tests.push(...suiteTests);
+ }
+
+ // Recursively process nested suites
+ if (suite.suites) {
+ suite.suites.forEach(nestedSuite => {
+ const nestedTests = extractTestsFromSuite(nestedSuite, fullSuiteTitle);
+ tests.push(...nestedTests);
+ });
+ }
+
+ return tests;
+}
+
+/**
+ * Compares current and main branch test results
+ * @param {Array} currentTests - Tests from current branch
+ * @param {Array} mainTests - Tests from main branch
+ * @returns {Object} Test comparison results
+ */
+function compareTests(currentTests, mainTests) {
+ const comparison = { new: [], skipped: [], deleted: [] };
+
+ const currentTestMap = new Map(currentTests.map(t => [t.fullTitle, t]));
+ const mainTestMap = new Map(mainTests.map(t => [t.fullTitle, t]));
+
+ // Find new and skipped tests
+ for (const [fullTitle, test] of currentTestMap) {
+ if (!mainTestMap.has(fullTitle)) {
+ comparison.new.push(`${test.title} (${test.file})`);
+ }
+ if (test.skipped) {
+ comparison.skipped.push(`${test.title} (${test.file})`);
+ }
+ }
+
+ // Find deleted tests
+ for (const [fullTitle, test] of mainTestMap) {
+ if (!currentTestMap.has(fullTitle)) {
+ comparison.deleted.push(`${test.title} (${test.file})`);
+ }
+ }
+
+ comparison.skipped = Array.from(new Set(comparison.skipped));
+ return comparison;
+}
+
+module.exports = {
+ isTestSkipped,
+ extractTestsFromSuite,
+ compareTests
+};
diff --git a/config-overrides.js b/config-overrides.js
index 243ec5675..a16b73a0f 100644
--- a/config-overrides.js
+++ b/config-overrides.js
@@ -60,6 +60,16 @@ module.exports = {
// see https://github.com/timarney/react-app-rewired/issues/241
config.transformIgnorePatterns = ['node_modules/(?!(@gravity-ui)/)'];
+ // Add .github directory to roots
+ config.roots = ['/src', '/.github'];
+
+ // Update testMatch to include .github directory
+ config.testMatch = [
+ '/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
+ '/src/**/*.{spec,test}.{js,jsx,ts,tsx}',
+ '/.github/**/*.{spec,test}.{js,jsx,ts,tsx}',
+ ];
+
return config;
},
};
diff --git a/tests/suites/tenant/queryEditor/queryEditor.test.ts b/tests/suites/tenant/queryEditor/queryEditor.test.ts
index 2d14840fd..c02f8e9de 100644
--- a/tests/suites/tenant/queryEditor/queryEditor.test.ts
+++ b/tests/suites/tenant/queryEditor/queryEditor.test.ts
@@ -90,7 +90,7 @@ test.describe('Test Query Editor', async () => {
await expect(queryEditor.isExplainButtonEnabled()).resolves.toBe(true);
});
- test('Stop button and elapsed time label appears when query is running', async ({page}) => {
+ test('Stop button and elapsed time label appear when query is running', async ({page}) => {
const queryEditor = new QueryEditor(page);
await queryEditor.setQuery(longRunningQuery);
@@ -100,7 +100,7 @@ test.describe('Test Query Editor', async () => {
await expect(queryEditor.isElapsedTimeVisible()).resolves.toBe(true);
});
- test('Stop button and elapsed time label disappears after query is stopped', async ({page}) => {
+ test('Stop button and elapsed time label disappear after query is stopped', async ({page}) => {
const queryEditor = new QueryEditor(page);
await queryEditor.setQuery(longRunningQuery);
diff --git a/tests/suites/tenant/queryEditor/queryTemplates.test.ts b/tests/suites/tenant/queryEditor/queryTemplates.test.ts
index f5e098db6..ea17b6b58 100644
--- a/tests/suites/tenant/queryEditor/queryTemplates.test.ts
+++ b/tests/suites/tenant/queryEditor/queryTemplates.test.ts
@@ -121,7 +121,7 @@ test.describe('Query Templates', () => {
await expect(queryEditor.editorTextArea).toHaveValue(initialContent);
});
- test('Dont save button in unsaved changes modal allows text to change', async ({page}) => {
+ test('Dont save button in unsaved changes modal allows to change text', async ({page}) => {
const objectSummary = new ObjectSummary(page);
const unsavedChangesModal = new UnsavedChangesModal(page);
const queryEditor = new QueryEditor(page);