@imwz/wp-pattern-sentinel 0.2.4 → 0.2.6
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/README.md +22 -1
- package/package.json +1 -1
- package/src/editor.js +30 -1
- package/src/login.js +52 -29
- package/src/main.js +45 -6
package/README.md
CHANGED
|
@@ -134,7 +134,28 @@ src/
|
|
|
134
134
|
format.js log, formatResult, printSummary
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
-
Each worker gets its own authenticated `BrowserContext` so session failures are isolated. Login happens once
|
|
137
|
+
Each worker gets its own authenticated `BrowserContext` so session failures are isolated. Login happens once, then cookies are shared across all contexts — concurrent logins are never attempted.
|
|
138
|
+
|
|
139
|
+
### Login resilience
|
|
140
|
+
|
|
141
|
+
If the WordPress login page times out (common on slow local VMs), sentinel retries automatically with exponential backoff:
|
|
142
|
+
|
|
143
|
+
| Attempt | Wait before retry |
|
|
144
|
+
|---------|-------------------|
|
|
145
|
+
| 1st | — |
|
|
146
|
+
| 2nd | 5 s |
|
|
147
|
+
| 3rd | 15 s |
|
|
148
|
+
| 4th (final) | 30 s |
|
|
149
|
+
|
|
150
|
+
Credential rejections (wrong password) are not retried — only timeout errors trigger the backoff.
|
|
151
|
+
|
|
152
|
+
### Real-time output
|
|
153
|
+
|
|
154
|
+
Each pattern result is printed to the terminal as soon as that worker finishes, rather than buffering everything until the full batch completes. During a long concurrent run you see progress immediately.
|
|
155
|
+
|
|
156
|
+
### Failure log
|
|
157
|
+
|
|
158
|
+
When any pattern fails, sentinel automatically writes a `sentinel-<timestamp>.log.json` file in the current working directory and prints the path after the summary. This preserves error details for later inspection without needing to re-run.
|
|
138
159
|
|
|
139
160
|
## npm publish
|
|
140
161
|
|
package/package.json
CHANGED
package/src/editor.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { log } from './format.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Replace inline PHP expressions with static equivalents so the block editor
|
|
5
|
+
* receives valid markup and the round-trip comparison stays accurate.
|
|
6
|
+
*
|
|
7
|
+
* Handles the PHP patterns that appear in Elayne theme patterns:
|
|
8
|
+
* <?php esc_html_e( 'Text', 'elayne' ); ?> → Text
|
|
9
|
+
* <?php echo esc_html__( 'Text', 'elayne' ); ?> → Text
|
|
10
|
+
* <?php esc_attr_e( 'Alt text', 'elayne' ); ?> → Alt text
|
|
11
|
+
* <?php echo esc_attr__( 'Alt text', 'elayne' ); ?> → Alt text
|
|
12
|
+
* <?php echo esc_url( get_template_directory_uri() ); ?> → http://example.com
|
|
13
|
+
* All other <?php ... ?> blocks → removed
|
|
14
|
+
*/
|
|
15
|
+
function stripPhpForValidation(content) {
|
|
16
|
+
if (!content.includes('<?php')) return content;
|
|
17
|
+
|
|
18
|
+
return content
|
|
19
|
+
.replace(/<\?php\s+(?:esc_html_e|esc_html__)\s*\(\s*'([^']+)'\s*,\s*'[^']+'\s*\)\s*;?\s*\?>/g, '$1')
|
|
20
|
+
.replace(/<\?php\s+(?:esc_html_e|esc_html__)\s*\(\s*"([^"]+)"\s*,\s*"[^"]+"\s*\)\s*;?\s*\?>/g, '$1')
|
|
21
|
+
.replace(/<\?php\s+echo\s+esc_html__\s*\(\s*'([^']+)'\s*,\s*'[^']+'\s*\)\s*;?\s*\?>/g, '$1')
|
|
22
|
+
.replace(/<\?php\s+(?:esc_attr_e|esc_attr__)\s*\(\s*'([^']+)'\s*,\s*'[^']+'\s*\)\s*;?\s*\?>/g, '$1')
|
|
23
|
+
.replace(/<\?php\s+(?:esc_attr_e|esc_attr__)\s*\(\s*"([^"]+)"\s*,\s*"[^"]+"\s*\)\s*;?\s*\?>/g, '$1')
|
|
24
|
+
.replace(/<\?php\s+echo\s+esc_attr__\s*\(\s*'([^']+)'\s*,\s*'[^']+'\s*\)\s*;?\s*\?>/g, '$1')
|
|
25
|
+
.replace(/<\?php\s+echo\s+esc_url\s*\([\s\S]*?\)\s*;?\s*\?>/g, 'http://example.com')
|
|
26
|
+
.replace(/<\?php[\s\S]*?\?>/g, '')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
3
30
|
/**
|
|
4
31
|
* Strip the PHP file header (opening tag + docblock) and return the raw block markup.
|
|
32
|
+
* Inline PHP expressions within the block markup are replaced with static values
|
|
33
|
+
* so the editor receives valid content for round-trip validation.
|
|
5
34
|
* Returns null if no block comment is found.
|
|
6
35
|
*
|
|
7
36
|
* WordPress pattern files look like:
|
|
@@ -21,7 +50,7 @@ export function extractBlockContent(fileContent) {
|
|
|
21
50
|
.trim();
|
|
22
51
|
|
|
23
52
|
if (!stripped.startsWith('<!--')) return null;
|
|
24
|
-
return stripped;
|
|
53
|
+
return stripPhpForValidation(stripped);
|
|
25
54
|
}
|
|
26
55
|
|
|
27
56
|
/**
|
package/src/login.js
CHANGED
|
@@ -1,40 +1,63 @@
|
|
|
1
1
|
import { log } from './format.js';
|
|
2
2
|
|
|
3
|
+
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // ms between attempts 1→2, 2→3, 3→4
|
|
4
|
+
|
|
3
5
|
export async function loginToWordPress(page, adminUrl, user, pass) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
timeout: 60000,
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
// Already logged in — editor loaded directly
|
|
13
|
-
if (await page.locator('.edit-post-layout, .editor-styles-wrapper').count() > 0) {
|
|
14
|
-
return true;
|
|
6
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
|
7
|
+
if (attempt > 0) {
|
|
8
|
+
const delay = RETRY_DELAYS[attempt - 1];
|
|
9
|
+
log(` Login timeout — waiting ${delay / 1000}s before retry ${attempt}/${RETRY_DELAYS.length}...`, 'yellow');
|
|
10
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
15
11
|
}
|
|
16
12
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const loggedIn = page.url().includes('/wp-admin/') && !page.url().includes('wp-login.php');
|
|
29
|
-
if (!loggedIn) {
|
|
30
|
-
const error = await page.textContent('#login_error').catch(() => null);
|
|
31
|
-
log(`Login failed: ${error?.trim() ?? 'unexpected URL after submit: ' + page.url()}`, 'red');
|
|
32
|
-
return false;
|
|
13
|
+
try {
|
|
14
|
+
const result = await attemptLogin(page, adminUrl, user, pass);
|
|
15
|
+
return result; // true = success, false = credential error (don't retry)
|
|
16
|
+
} catch (err) {
|
|
17
|
+
const shortMsg = err.message.split('\n')[0];
|
|
18
|
+
if (attempt < RETRY_DELAYS.length) {
|
|
19
|
+
log(` Login attempt ${attempt + 1} timed out: ${shortMsg}`, 'yellow');
|
|
20
|
+
} else {
|
|
21
|
+
log(`Login failed after ${RETRY_DELAYS.length + 1} attempts: ${shortMsg}`, 'red');
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
33
24
|
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function attemptLogin(page, adminUrl, user, pass) {
|
|
31
|
+
// Navigate to the editor — WordPress redirects to wp-login.php if unauthenticated,
|
|
32
|
+
// with redirect_to preserving the editor URL (important for multisite subsite context).
|
|
33
|
+
await page.goto(`${adminUrl}/wp-admin/post-new.php?post_type=page`, {
|
|
34
|
+
waitUntil: 'domcontentloaded',
|
|
35
|
+
timeout: 60000,
|
|
36
|
+
});
|
|
34
37
|
|
|
38
|
+
// Already logged in — editor loaded directly
|
|
39
|
+
if (await page.locator('.edit-post-layout, .editor-styles-wrapper').count() > 0) {
|
|
35
40
|
return true;
|
|
36
|
-
}
|
|
37
|
-
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Wait for the login form to be ready before filling
|
|
44
|
+
await page.waitForSelector('#user_login', { state: 'visible', timeout: 30000 });
|
|
45
|
+
await page.fill('#user_login', user);
|
|
46
|
+
await page.fill('#user_pass', pass);
|
|
47
|
+
|
|
48
|
+
// Start listening for navigation BEFORE clicking — avoids missing the redirect
|
|
49
|
+
await Promise.all([
|
|
50
|
+
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 }),
|
|
51
|
+
page.click('#wp-submit'),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const loggedIn = page.url().includes('/wp-admin/') && !page.url().includes('wp-login.php');
|
|
55
|
+
if (!loggedIn) {
|
|
56
|
+
const error = await page.textContent('#login_error').catch(() => null);
|
|
57
|
+
// Credential error — no point retrying
|
|
58
|
+
log(`Login rejected: ${error?.trim() ?? 'unexpected URL after submit: ' + page.url()}`, 'red');
|
|
38
59
|
return false;
|
|
39
60
|
}
|
|
61
|
+
|
|
62
|
+
return true;
|
|
40
63
|
}
|
package/src/main.js
CHANGED
|
@@ -35,12 +35,14 @@ export async function main() {
|
|
|
35
35
|
// reauth=1 redirect loop — serialising login avoids this entirely.
|
|
36
36
|
let contexts;
|
|
37
37
|
try {
|
|
38
|
+
log('Logging in to WordPress...', 'cyan');
|
|
38
39
|
const firstContext = await browser.newContext({ viewport: options.viewport });
|
|
39
40
|
const loginPage = await firstContext.newPage();
|
|
40
41
|
loginPage.setDefaultTimeout(60000);
|
|
41
42
|
const ok = await loginToWordPress(loginPage, options.adminUrl, options.user, options.pass);
|
|
42
43
|
await loginPage.close();
|
|
43
44
|
if (!ok) throw new Error('Failed to authenticate with WordPress');
|
|
45
|
+
log(`Authenticated — sharing session across ${options.concurrency} worker(s)`, 'green');
|
|
44
46
|
|
|
45
47
|
const cookies = await firstContext.cookies();
|
|
46
48
|
contexts = [firstContext];
|
|
@@ -58,10 +60,17 @@ export async function main() {
|
|
|
58
60
|
const queue = new PQueue({ concurrency: options.concurrency });
|
|
59
61
|
let idx = 0;
|
|
60
62
|
|
|
61
|
-
// Push ALL tasks without awaiting —
|
|
63
|
+
// Push ALL tasks without awaiting — workers run in parallel.
|
|
64
|
+
// Print each result immediately as it finishes (real-time feedback).
|
|
62
65
|
const promises = files.map(file => {
|
|
63
66
|
const context = contexts[idx++ % options.concurrency];
|
|
64
|
-
return queue.add(() =>
|
|
67
|
+
return queue.add(async () => {
|
|
68
|
+
const result = await validatePatternFile(file, options, context);
|
|
69
|
+
if (!options.json) {
|
|
70
|
+
console.log(formatResult(result));
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
});
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
const results = await Promise.all(promises);
|
|
@@ -71,18 +80,26 @@ export async function main() {
|
|
|
71
80
|
|
|
72
81
|
if (options.json) {
|
|
73
82
|
for (const r of results) console.log(JSON.stringify(r));
|
|
74
|
-
} else {
|
|
75
|
-
for (const r of results) console.log(formatResult(r));
|
|
76
|
-
printSummary(results);
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
|
|
85
|
+
printSummary(results);
|
|
86
|
+
|
|
87
|
+
// Write a log file when any pattern failed so details are preserved for inspection.
|
|
88
|
+
const hasFailed = results.some(r => !r.passed);
|
|
89
|
+
if (hasFailed) {
|
|
90
|
+
const logPath = await writeLogFile(results);
|
|
91
|
+
if (logPath) log(` Log saved → ${logPath}`, 'gray');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
process.exit(hasFailed ? 1 : 0);
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
async function validatePatternFile(patternPath, options, context) {
|
|
83
98
|
const patternName = path.basename(patternPath);
|
|
84
99
|
const startTime = Date.now();
|
|
85
100
|
|
|
101
|
+
log(` Validating: ${patternName}`, 'cyan');
|
|
102
|
+
|
|
86
103
|
let fileContent;
|
|
87
104
|
try {
|
|
88
105
|
fileContent = await fs.promises.readFile(patternPath, 'utf8');
|
|
@@ -153,3 +170,25 @@ function fail(pattern, startTime, type, message) {
|
|
|
153
170
|
duration: Date.now() - startTime,
|
|
154
171
|
};
|
|
155
172
|
}
|
|
173
|
+
|
|
174
|
+
async function writeLogFile(results) {
|
|
175
|
+
try {
|
|
176
|
+
const now = new Date();
|
|
177
|
+
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
178
|
+
const logPath = path.join(process.cwd(), `sentinel-${ts}.log.json`);
|
|
179
|
+
const payload = {
|
|
180
|
+
timestamp: now.toISOString(),
|
|
181
|
+
results: results.map(r => ({
|
|
182
|
+
pattern: r.pattern,
|
|
183
|
+
passed: r.passed,
|
|
184
|
+
duration: r.duration,
|
|
185
|
+
errors: r.errors,
|
|
186
|
+
warnings: r.warnings,
|
|
187
|
+
})),
|
|
188
|
+
};
|
|
189
|
+
await fs.promises.writeFile(logPath, JSON.stringify(payload, null, 2));
|
|
190
|
+
return logPath;
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|