@imwz/wp-pattern-sentinel 1.1.3 → 1.1.4

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
@@ -125,7 +125,7 @@ node bin/sentinel.js --verbose --url=... path/to/patterns/
125
125
  | `--concurrency` | `4` | Parallel workers |
126
126
  | `--json` | `false` | Output JSON (one result per line) |
127
127
  | `--keep-page` | `false` | Don't delete draft pages after validation |
128
- | `--verbose` | `false` | Show detailed step-by-step progress for each pattern |
128
+ | `--verbose` | `false` | Show detailed step-by-step progress for each pattern, with the time each step took |
129
129
  | `--width` | `1280` | Viewport width |
130
130
  | `--height` | `800` | Viewport height |
131
131
  | `--cache` | `false` | Skip patterns that previously passed with the same file content (see [Pass cache](#pass-cache)) |
@@ -231,3 +231,5 @@ npx wp-pattern-sentinel --url=http://imagewize.test --user=admin --pass=secret p
231
231
 
232
232
  - `0` — all patterns passed
233
233
  - `1` — one or more patterns failed
234
+
235
+ A `page_creation_error` means the block editor didn't load, so the pattern was never tested. This happens when several workers load the editor at once on a small PHP-FPM pool (e.g. Laravel Valet) and some editor scripts return 502. Sentinel detects this as soon as the page loads and retries up to three times, with a randomized delay, before giving up. The summary counts these failures as infrastructure errors, separate from validation failures. They still exit with `1`. If they keep happening, lower `--concurrency`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imwz/wp-pattern-sentinel",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Browser-based WordPress block pattern validator using Playwright",
5
5
  "type": "module",
6
6
  "engines": {
package/src/editor.js CHANGED
@@ -61,46 +61,94 @@ export function extractBlockContent(fileContent) {
61
61
  return stripPhpForValidation(stripped);
62
62
  }
63
63
 
64
+ const PAGE_CREATION_RETRY_DELAYS = [3_000, 8_000, 15_000]; // ms before retries 1, 2, 3 (plus jitter)
65
+
64
66
  /**
65
- * Navigate to a new draft page and return its post ID.
67
+ * Navigate to a new draft page and return its post ID, or null on failure.
66
68
  * WordPress redirects post-new.php → post.php?post=ID&action=edit,
67
69
  * so we can read the ID directly from the final URL.
70
+ *
71
+ * When several workers open post-new.php at once, each cold editor requests
72
+ * ~150 scripts. On a small local PHP-FPM pool (Laravel Valet routes even
73
+ * static files through PHP) the socket backlog overflows and some scripts
74
+ * come back 502, so the editor never boots (#21). That's an infrastructure
75
+ * error, not a pattern failure: detect it as soon as the document has loaded
76
+ * and retry after a jittered backoff, so workers don't collide again.
68
77
  */
69
78
  export async function createDraftPage(page, baseUrl, verbose = false) {
79
+ for (let attempt = 0; attempt <= PAGE_CREATION_RETRY_DELAYS.length; attempt++) {
80
+ if (attempt > 0) {
81
+ const base = PAGE_CREATION_RETRY_DELAYS[attempt - 1];
82
+ const delay = base + Math.round(Math.random() * base);
83
+ log(` Editor load failed — waiting ${(delay / 1000).toFixed(1)}s before retry ${attempt}/${PAGE_CREATION_RETRY_DELAYS.length}...`, 'yellow');
84
+ await new Promise(resolve => setTimeout(resolve, delay));
85
+ }
86
+
87
+ try {
88
+ return await attemptCreateDraftPage(page, baseUrl, verbose);
89
+ } catch (error) {
90
+ const shortMsg = error.message.split('\n')[0];
91
+ if (attempt < PAGE_CREATION_RETRY_DELAYS.length) {
92
+ log(` Draft page attempt ${attempt + 1} failed: ${shortMsg}`, 'yellow');
93
+ } else {
94
+ log(`Failed to create draft page after ${attempt + 1} attempts: ${error.message}`, 'red');
95
+ }
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ async function attemptCreateDraftPage(page, baseUrl, verbose) {
102
+ const start = Date.now();
103
+ if (verbose) log(' → Creating draft page...', 'gray');
104
+
105
+ // Editor scripts are parser-blocking, so every one has responded by
106
+ // domcontentloaded. A server error on any of them means the editor can't
107
+ // boot — fail now instead of waiting out the selector timeout.
108
+ const brokenScripts = [];
109
+ const onResponse = response => {
110
+ if (response.status() >= 500 && response.request().resourceType() === 'script') {
111
+ brokenScripts.push(response.status());
112
+ }
113
+ };
114
+ page.on('response', onResponse);
70
115
  try {
71
- if (verbose) log(' → Creating draft page...', 'gray');
72
116
  await page.goto(`${baseUrl}/wp-admin/post-new.php?post_type=page`, {
73
117
  waitUntil: 'domcontentloaded',
74
- timeout: 30000,
75
- });
76
-
77
- await page.waitForSelector('.edit-post-layout, .editor-styles-wrapper', {
78
- timeout: 30000,
79
118
  });
119
+ } finally {
120
+ page.off('response', onResponse);
121
+ }
122
+ if (brokenScripts.length > 0) {
123
+ throw new Error(
124
+ `${brokenScripts.length} editor script(s) failed to load (HTTP ${[...new Set(brokenScripts)].join(', ')}) — server overloaded`
125
+ );
126
+ }
127
+ if (verbose) log(` → post-new.php loaded (${Date.now() - start}ms)`, 'gray');
80
128
 
81
- const url = page.url();
82
- const match = url.match(/[?&]post=(\d+)/);
83
- if (match) {
84
- if (verbose) log(' → Draft page created', 'gray');
85
- return parseInt(match[1], 10);
86
- }
129
+ await page.waitForSelector('.edit-post-layout, .editor-styles-wrapper');
87
130
 
88
- // Fallback: read from wp.data (editor may not have redirected yet)
89
- const pageId = await page.evaluate(() =>
90
- window.wp?.data?.select('core/editor')?.getCurrentPostId?.() ?? null
91
- );
92
- if (verbose && pageId) log(' → Draft page created', 'gray');
93
- return pageId;
94
- } catch (error) {
95
- log(`Failed to create draft page: ${error.message}`, 'red');
96
- return null;
131
+ const url = page.url();
132
+ const match = url.match(/[?&]post=(\d+)/);
133
+ if (match) {
134
+ if (verbose) log(` → Draft page created (${Date.now() - start}ms)`, 'gray');
135
+ return parseInt(match[1], 10);
97
136
  }
137
+
138
+ // Fallback: read from wp.data (editor may not have redirected yet)
139
+ const pageId = await page.evaluate(() =>
140
+ window.wp?.data?.select('core/editor')?.getCurrentPostId?.() ?? null
141
+ );
142
+ if (pageId === null) throw new Error('Editor loaded but no post ID was found');
143
+ if (verbose) log(` → Draft page created (${Date.now() - start}ms)`, 'gray');
144
+ return pageId;
98
145
  }
99
146
 
100
147
  /**
101
148
  * Set the editor content via wp.data and wait for blocks to parse.
102
149
  */
103
150
  export async function insertPatternIntoEditor(page, blockContent, verbose = false) {
151
+ const start = Date.now();
104
152
  try {
105
153
  if (verbose) log(' → Inserting pattern into editor...', 'gray');
106
154
  await page.evaluate(content => {
@@ -110,10 +158,11 @@ export async function insertPatternIntoEditor(page, blockContent, verbose = fals
110
158
  // Wait until at least one block is present
111
159
  await page.waitForFunction(
112
160
  () => window.wp.data.select('core/block-editor').getBlocks().length > 0,
161
+ null,
113
162
  { timeout: 15000 }
114
163
  );
115
164
 
116
- if (verbose) log(' → Pattern inserted', 'gray');
165
+ if (verbose) log(` → Pattern inserted (${Date.now() - start}ms)`, 'gray');
117
166
  return true;
118
167
  } catch (error) {
119
168
  log(`Failed to insert pattern: ${error.message}`, 'red');
@@ -126,24 +175,24 @@ export async function insertPatternIntoEditor(page, blockContent, verbose = fals
126
175
  */
127
176
  export async function savePage(page, verbose = false) {
128
177
  const result = { success: false, errors: [], warnings: [] };
178
+ const start = Date.now();
129
179
  try {
130
180
  if (verbose) log(' → Saving page...', 'gray');
181
+ // savePost() resolves once the save request and its hooks have finished,
182
+ // and evaluate() awaits it — so isSavingPost() is already false here.
183
+ // Don't wait for the save to *start*: that condition never becomes true
184
+ // again and the wait runs to its full timeout (the 60s stall in #21).
131
185
  await page.evaluate(() => window.wp.data.dispatch('core/editor').savePost());
132
186
 
133
- // Wait for save to start, then finish
134
- await page
135
- .waitForFunction(
136
- () => window.wp.data.select('core/editor').isSavingPost(),
137
- { timeout: 5000 }
138
- )
139
- .catch(() => {}); // save might be near-instant
140
-
187
+ // waitForFunction's second parameter is the page-function arg, so options
188
+ // must go third — passed second, the timeout is silently ignored.
141
189
  await page.waitForFunction(
142
190
  () => !window.wp.data.select('core/editor').isSavingPost(),
191
+ null,
143
192
  { timeout: 30000 }
144
193
  );
145
194
 
146
- if (verbose) log(' → Page saved', 'gray');
195
+ if (verbose) log(` → Page saved (${Date.now() - start}ms)`, 'gray');
147
196
  result.success = true;
148
197
  } catch (error) {
149
198
  result.errors.push({ type: 'save_error', message: error.message });
@@ -156,6 +205,7 @@ export async function savePage(page, verbose = false) {
156
205
  * Non-fatal — a failure here does not affect validation results.
157
206
  */
158
207
  export async function deletePage(page, baseUrl, pageId, verbose = false) {
208
+ const start = Date.now();
159
209
  try {
160
210
  if (verbose) log(' → Deleting draft page...', 'gray');
161
211
  await page.evaluate(async id => {
@@ -165,7 +215,7 @@ export async function deletePage(page, baseUrl, pageId, verbose = false) {
165
215
  headers: { 'X-WP-Nonce': nonce },
166
216
  });
167
217
  }, pageId);
168
- if (verbose) log(' → Draft page deleted', 'gray');
218
+ if (verbose) log(` → Draft page deleted (${Date.now() - start}ms)`, 'gray');
169
219
  } catch {
170
220
  // Non-fatal
171
221
  }
package/src/format.js CHANGED
@@ -7,6 +7,14 @@ const C = {
7
7
  gray: '\x1b[90m',
8
8
  };
9
9
 
10
+ /**
11
+ * Error types that mean the pattern could not be tested at all (the editor
12
+ * never loaded), as opposed to the pattern failing validation.
13
+ */
14
+ const INFRA_ERRORS = new Set(['page_creation_error']);
15
+
16
+ const isInfraFailure = r => !r.passed && r.errors.some(e => INFRA_ERRORS.has(e.type));
17
+
10
18
  export function log(message, color = 'reset') {
11
19
  console.log(`${C[color] ?? C.reset}${message}${C.reset}`);
12
20
  }
@@ -31,6 +39,7 @@ export function formatResult(result) {
31
39
  export function printSummary(results, skipped = 0) {
32
40
  const passed = results.filter(r => r.passed).length;
33
41
  const failed = results.filter(r => !r.passed).length;
42
+ const infra = results.filter(isInfraFailure).length;
34
43
  const totalErrors = results.reduce((n, r) => n + r.errors.length, 0);
35
44
  const totalWarns = results.reduce((n, r) => n + r.warnings.length, 0);
36
45
  const totalMs = results.reduce((n, r) => n + r.duration, 0);
@@ -43,6 +52,9 @@ export function printSummary(results, skipped = 0) {
43
52
  if (skipped > 0) log(`Skipped : ${skipped} (cached)`, 'gray');
44
53
  log(`Passed : ${passed}`, passed > 0 ? 'green' : 'gray');
45
54
  log(`Failed : ${failed}`, failed > 0 ? 'red' : 'gray');
55
+ if (infra > 0) {
56
+ log(` of which : ${infra} infrastructure (editor did not load — re-run, or lower --concurrency)`, 'yellow');
57
+ }
46
58
  log(`Errors : ${totalErrors}`, totalErrors > 0 ? 'red' : 'gray');
47
59
  log(`Warnings : ${totalWarns}`, totalWarns > 0 ? 'yellow' : 'gray');
48
60
  log(`Time : ${totalMs}ms (avg ${avgMs}ms/pattern)`);
package/src/main.js CHANGED
@@ -201,7 +201,7 @@ async function validatePatternFile(patternPath, options, context) {
201
201
  try {
202
202
  const pageId = await createDraftPage(page, options.adminUrl, verbose);
203
203
  if (pageId === null) {
204
- return fail(patternName, patternPath, startTime, 'page_creation_error', 'Failed to create test page');
204
+ return fail(patternName, patternPath, startTime, 'page_creation_error', 'Editor did not load after retries — not a pattern problem; re-run or lower --concurrency');
205
205
  }
206
206
 
207
207
  const refErrors = await checkPatternRefs(page, slugs, verbose);
package/src/validation.js CHANGED
@@ -4,6 +4,7 @@ import { log } from './format.js';
4
4
  * Walk the block tree and collect any blocks where isValid === false.
5
5
  */
6
6
  export async function checkBlockValidation(page, verbose = false) {
7
+ const start = Date.now();
7
8
  if (verbose) log(' → Checking block validation...', 'gray');
8
9
  try {
9
10
  const errors = await page.evaluate(() => {
@@ -26,7 +27,7 @@ export async function checkBlockValidation(page, verbose = false) {
26
27
  ]);
27
28
  return walk(window.wp.data.select('core/block-editor').getBlocks());
28
29
  });
29
- if (verbose) log(' → Block validation complete', 'gray');
30
+ if (verbose) log(` → Block validation complete (${Date.now() - start}ms)`, 'gray');
30
31
  return errors;
31
32
  } catch (error) {
32
33
  log(`Block validation check error: ${error.message}`, 'yellow');
@@ -156,6 +157,7 @@ export async function checkPatternRefs(page, slugs, verbose = false) {
156
157
  */
157
158
  export async function compareContent(page, originalContent, verbose = false) {
158
159
  const result = { matches: true, errors: [], warnings: [], savedContent: null };
160
+ const start = Date.now();
159
161
 
160
162
  try {
161
163
  if (verbose) log(' → Comparing content...', 'gray');
@@ -166,7 +168,7 @@ export async function compareContent(page, originalContent, verbose = false) {
166
168
 
167
169
  const normalize = str => str.replace(/\s+/g, ' ').trim();
168
170
  if (normalize(normalizeForComparison(savedContent)) === normalize(normalizeForComparison(originalContent))) {
169
- if (verbose) log(' → Content matches', 'gray');
171
+ if (verbose) log(` → Content matches (${Date.now() - start}ms)`, 'gray');
170
172
  return result;
171
173
  }
172
174
 
@@ -190,7 +192,7 @@ export async function compareContent(page, originalContent, verbose = false) {
190
192
  message: `Content injected by editor:\n ${added.join('\n ')}`,
191
193
  });
192
194
  }
193
- if (verbose) log(' → Content comparison complete', 'gray');
195
+ if (verbose) log(` → Content comparison complete (${Date.now() - start}ms)`, 'gray');
194
196
  } catch (error) {
195
197
  result.errors.push({ type: 'comparison_error', message: error.message });
196
198
  }