@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 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 per context before the queue starts.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imwz/wp-pattern-sentinel",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Browser-based WordPress block pattern validator using Playwright",
5
5
  "type": "module",
6
6
  "engines": {
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
- try {
5
- // Navigate to the editor — WordPress redirects to wp-login.php if unauthenticated,
6
- // with redirect_to preserving the editor URL (important for multisite subsite context).
7
- await page.goto(`${adminUrl}/wp-admin/post-new.php?post_type=page`, {
8
- waitUntil: 'domcontentloaded',
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
- // Wait for the login form to be ready before filling
18
- await page.waitForSelector('#user_login', { state: 'visible', timeout: 30000 });
19
- await page.fill('#user_login', user);
20
- await page.fill('#user_pass', pass);
21
-
22
- // Start listening for navigation BEFORE clicking — avoids missing the redirect
23
- await Promise.all([
24
- page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 60000 }),
25
- page.click('#wp-submit'),
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
- } catch (error) {
37
- log(`Login failed: ${error.message}`, 'red');
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 — this is what makes workers run in parallel
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(() => validatePatternFile(file, options, context));
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
- process.exit(results.some(r => !r.passed) ? 1 : 0);
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
+ }