-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathvalidate-phpunit-cleanup-pr.yml
More file actions
247 lines (210 loc) · 11.8 KB
/
Copy pathvalidate-phpunit-cleanup-pr.yml
File metadata and controls
247 lines (210 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
name: Validate PHPUnit Test Cleanup Files
on:
pull_request:
types: [ opened, synchronize, reopened ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true
permissions: {}
jobs:
validate-files:
name: Validate PHPUnit Test Files
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.ref }}
- name: Validate Test File Structure, Namespaces, Class Names, and @covers Annotations
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require( 'fs' );
const path = require( 'path' );
const pr = context.payload.pull_request;
if ( ! pr ) {
core.info( 'No pull request found in event payload. Skipping PR file validation.' );
return;
}
core.info( `Validating test files in PR #${pr.number}...` );
// Fetch list of files changed in the pull request
const changedFiles = await github.paginate( github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
} );
// Helper to get allowed namespace segment variations from a path segment
function normalizeSegment( segment ) {
const lower = segment.toLowerCase();
if ( lower === 'wp-includes' ) return [ 'WP_Includes' ];
if ( lower === 'wp-admin' ) return [ 'WP_Admin' ];
if ( lower === 'admin' ) return [ 'Admin', 'WP_Admin' ];
if ( lower === 'includes' ) return [ 'Includes', 'WP_Includes' ];
const pascal = segment.replace( /(?:^|-|_)+([a-zA-Z0-9])/g, ( _, chr ) => chr.toUpperCase() );
const upperUnderscore = segment.replace( /-/g, '_' );
return Array.from( new Set( [ segment, pascal, upperUnderscore ] ) );
}
const errors = [];
const passes = [];
let validatedCount = 0;
for ( const file of changedFiles ) {
const filename = file.filename.replace( /\\/g, '/' );
// Check only files within tests/phpunit/tests/
if ( ! filename.startsWith( 'tests/phpunit/tests/' ) || ! filename.endsWith( '.php' ) ) {
continue;
}
// Skip deleted files
if ( file.status === 'removed' ) {
core.info( `Skipping deleted file: ${filename}` );
continue;
}
const relPath = filename.slice( 'tests/phpunit/tests/'.length );
const pathParts = relPath.split( '/' );
const baseName = pathParts.pop();
// Skip non-test support files, fixtures, or data directories
if ( baseName === 'base.php' || pathParts.includes( 'data' ) || pathParts.includes( 'fixtures' ) ) {
core.info( `Skipping helper/fixture file: ${filename}` );
continue;
}
validatedCount++;
core.info( `\nValidating test file: ${filename}` );
// 1. Validate Filename conventions (starts with capital letter, CamelCase/PascalCase, ends with Test.php)
const expectedClassName = baseName.replace( /\.php$/, '' );
let filenameValid = true;
if ( ! /^[A-Z]/.test( baseName ) ) {
errors.push( `[${filename}] Test filename "${baseName}" must start with a capital letter.` );
filenameValid = false;
}
if ( ! baseName.endsWith( 'Test.php' ) ) {
errors.push( `[${filename}] Test filename "${baseName}" must end with "Test.php" (Pattern: FunctionUnderTest[OptionalSubsetIndicator]Test.php).` );
filenameValid = false;
}
if ( ! /^[A-Z][a-zA-Z0-9_]*Test\.php$/.test( baseName ) ) {
errors.push( `[${filename}] Test filename "${baseName}" must follow CamelCase / PascalCase convention and end with "Test.php".` );
filenameValid = false;
}
if ( filenameValid ) {
passes.push( `[${filename}] Filename "${baseName}" starts with a capital letter, follows CamelCase convention, and ends with "Test.php".` );
}
// 2. Validate directory depth (must have a parent directory for Class_Under_Test or Functions_FileName)
if ( pathParts.length < 1 ) {
errors.push( `[${filename}] Test file must reside in a directory representing Class_Under_Test or Functions_FileName.` );
}
// Read file content from checked out workspace
let fileContent = '';
try {
fileContent = fs.readFileSync( path.join( process.env.GITHUB_WORKSPACE || '.', filename ), 'utf8' );
} catch ( e ) {
errors.push( `[${filename}] Could not read file content: ${e.message}` );
continue;
}
// 3. Validate Namespace declaration
const nsMatch = fileContent.match( /^\s*namespace\s+([^;]+);/m );
if ( ! nsMatch ) {
errors.push( `[${filename}] Missing namespace declaration.` );
} else {
const declaredNs = nsMatch[1].trim();
if ( ! declaredNs.startsWith( 'WordPress\\Tests' ) ) {
errors.push( `[${filename}] Namespace "${declaredNs}" must start with "WordPress\\Tests".` );
} else {
const nsParts = declaredNs.split( '\\' ).slice( 2 ); // Remove 'WordPress' and 'Tests'
if ( nsParts.length !== pathParts.length ) {
errors.push( `[${filename}] Namespace depth (${nsParts.length}: "${declaredNs}") does not match directory depth (${pathParts.length}: "${pathParts.join('/')}").` );
} else {
let nsMatchFailed = false;
for ( let i = 0; i < pathParts.length; i++ ) {
const expectedVariants = normalizeSegment( pathParts[i] );
const actualSegment = nsParts[i];
const matched = expectedVariants.some( ( v ) => v.toLowerCase() === actualSegment.toLowerCase() );
if ( ! matched ) {
errors.push( `[${filename}] Namespace segment "${actualSegment}" in "${declaredNs}" does not match directory segment "${pathParts[i]}" (expected one of: ${expectedVariants.join( ', ' )}).` );
nsMatchFailed = true;
}
}
if ( ! nsMatchFailed ) {
passes.push( `[${filename}] Namespace "${declaredNs}" accurately matches file path.` );
}
}
}
}
// 4. Validate Class Name declaration
const classMatches = [];
const classRegex = /^\s*(?:final\s+|abstract\s+)?class\s+([a-zA-Z0-9_]+)/gm;
let match;
while ( ( match = classRegex.exec( fileContent ) ) !== null ) {
classMatches.push( match[1] );
}
if ( classMatches.length === 0 ) {
errors.push( `[${filename}] No class declaration found in file.` );
} else if ( classMatches.length > 1 ) {
errors.push( `[${filename}] Declares multiple classes (${classMatches.join(', ')}). Exactly one test class is allowed per file.` );
} else {
const declaredClass = classMatches[0];
if ( declaredClass !== expectedClassName ) {
errors.push( `[${filename}] Class name "${declaredClass}" does not match filename "${baseName}" (expected "${expectedClassName}").` );
} else {
passes.push( `[${filename}] Class name "${declaredClass}" matches filename.` );
}
if ( ! declaredClass.endsWith( 'Test' ) ) {
errors.push( `[${filename}] Class name "${declaredClass}" must end with "Test".` );
}
if ( ! /^[A-Z]/.test( declaredClass ) ) {
errors.push( `[${filename}] Class name "${declaredClass}" must start with a capital letter.` );
}
if ( ! /^[A-Z][a-zA-Z0-9_]*Test$/.test( declaredClass ) ) {
errors.push( `[${filename}] Class name "${declaredClass}" must follow CamelCase / PascalCase convention and end with "Test".` );
}
}
// 5. Validate @covers docblock annotations (must be in opening class comment and nowhere else)
const classDocMatch = fileContent.match( /\/\*\*([^*]*(?:\*(?!\/)[^*]*)*)\*\/\s*(?:final\s+|abstract\s+)?class\s+([a-zA-Z0-9_]+)/ );
if ( ! classDocMatch ) {
errors.push( `[${filename}] Missing opening class docblock comment preceding class declaration.` );
} else {
const classDocContent = classDocMatch[1];
const hasCoversInClassDoc = /@covers\b/.test( classDocContent );
if ( ! hasCoversInClassDoc ) {
errors.push( `[${filename}] Opening class docblock must contain a "@covers" annotation.` );
} else {
passes.push( `[${filename}] Opening class docblock contains "@covers" annotation.` );
}
// Check for @covers annotations outside the opening class docblock
const docblockStart = classDocMatch.index;
const docblockEnd = docblockStart + classDocMatch[0].length;
const coversRegex = /@covers\b/g;
let m;
const outsideLines = [];
while ( ( m = coversRegex.exec( fileContent ) ) !== null ) {
if ( m.index < docblockStart || m.index >= docblockEnd ) {
const lineNum = fileContent.substring( 0, m.index ).split( '\n' ).length;
outsideLines.push( lineNum );
}
}
if ( outsideLines.length > 0 ) {
errors.push( `[${filename}] Found ${outsideLines.length} "@covers" annotation(s) outside the opening class docblock (lines: ${outsideLines.join( ', ' )}). "@covers" annotations are only permitted in the opening class docblock.` );
} else if ( hasCoversInClassDoc ) {
passes.push( `[${filename}] No "@covers" annotations found outside the opening class docblock.` );
}
}
}
// Summary Output
core.info( `\n--- Validated ${validatedCount} Test File(s) ---` );
for ( const pass of passes ) {
core.info( `✓ ${pass}` );
}
if ( errors.length > 0 ) {
core.info( '\n--- File Validation Failures ---' );
for ( const err of errors ) {
core.error( `✗ ${err}` );
}
core.setFailed( `Pull request #${pr.number} failed test file specification validation with ${errors.length} error(s).` );
} else if ( validatedCount > 0 ) {
core.info( `\n✓ All ${validatedCount} test file(s) meet the path, namespace, and class name specifications!` );
} else {
core.info( '\nNo PHPUnit test files under tests/phpunit/tests/ were modified in this PR.' );
}