@imwz/wp-pattern-sentinel 0.2.5 → 0.3.0

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
@@ -120,6 +120,9 @@ node bin/sentinel.js --concurrency=6 --url=... path/to/patterns/
120
120
  | `--keep-page` | `false` | Don't delete draft pages after validation |
121
121
  | `--width` | `1280` | Viewport width |
122
122
  | `--height` | `800` | Viewport height |
123
+ | `--cache` | `false` | Skip patterns that previously passed with the same file content (see [Pass cache](#pass-cache)) |
124
+ | `--clear-cache` | `false` | Delete `.sentinel-cache.json` and exit (or combine with a path to clear then validate) |
125
+ | `--log` | `false` | Always write `sentinel-<timestamp>.log.json`, even when all patterns pass |
123
126
 
124
127
  ## Architecture
125
128
 
@@ -134,7 +137,60 @@ src/
134
137
  format.js log, formatResult, printSummary
135
138
  ```
136
139
 
137
- Each worker gets its own authenticated `BrowserContext` so session failures are isolated. Login happens once per context before the queue starts.
140
+ 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.
141
+
142
+ ### Login resilience
143
+
144
+ If the WordPress login page times out (common on slow local VMs), sentinel retries automatically with exponential backoff:
145
+
146
+ | Attempt | Wait before retry |
147
+ |---------|-------------------|
148
+ | 1st | — |
149
+ | 2nd | 5 s |
150
+ | 3rd | 15 s |
151
+ | 4th (final) | 30 s |
152
+
153
+ Credential rejections (wrong password) are not retried — only timeout errors trigger the backoff.
154
+
155
+ ### Real-time output
156
+
157
+ 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.
158
+
159
+ ### Pass cache
160
+
161
+ `--cache` stores a `.sentinel-cache.json` file in the working directory. Each entry records the file's content hash and the last pass result:
162
+
163
+ ```json
164
+ {
165
+ "patterns/main-hero.php": {
166
+ "hash": "a1b2c3d4e5f6",
167
+ "passed": true,
168
+ "checkedAt": "2026-05-16T10:00:00.000Z"
169
+ }
170
+ }
171
+ ```
172
+
173
+ On subsequent runs, if a pattern file's content hash matches the cached entry **and** it previously passed, the pattern is skipped. If the file has changed (even by one byte), it is re-validated and the cache entry is updated. Failed patterns are always removed from the cache so they are never skipped.
174
+
175
+ ```bash
176
+ # First run — validates all, populates cache
177
+ sentinel --cache --log patterns/
178
+
179
+ # Later runs — only validates new or changed patterns
180
+ sentinel --cache --log patterns/
181
+
182
+ # Reset the cache (e.g. after a theme.json change that affects all patterns)
183
+ sentinel --clear-cache
184
+
185
+ # Clear and immediately re-validate
186
+ sentinel --clear-cache --cache --log patterns/
187
+ ```
188
+
189
+ Commit `.sentinel-cache.json` to track validated state across sessions. Add it to `.gitignore` if you prefer each developer to maintain their own local cache.
190
+
191
+ ### Failure log
192
+
193
+ 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. Use `--log` to write the file even on a fully-passing run.
138
194
 
139
195
  ## npm publish
140
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imwz/wp-pattern-sentinel",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Browser-based WordPress block pattern validator using Playwright",
5
5
  "type": "module",
6
6
  "engines": {
package/src/args.js CHANGED
@@ -34,6 +34,10 @@ const ARG_OPTIONS = {
34
34
  concurrency: { type: 'string', default: '4' },
35
35
  width: { type: 'string', default: '1280' },
36
36
  height: { type: 'string', default: '800' },
37
+ // Persistence
38
+ cache: { type: 'boolean', default: false },
39
+ 'clear-cache': { type: 'boolean', default: false },
40
+ log: { type: 'boolean', default: false },
37
41
  };
38
42
 
39
43
  /**
@@ -126,6 +130,9 @@ export async function parseArgs(args) {
126
130
  headless: values.headless,
127
131
  json: values.json,
128
132
  keepPage: values['keep-page'],
133
+ cache: values.cache,
134
+ clearCache: values['clear-cache'],
135
+ log: values.log,
129
136
  concurrency: Math.max(1, parseInt(values.concurrency, 10)),
130
137
  viewport: {
131
138
  width: parseInt(values.width, 10),
package/src/format.js CHANGED
@@ -28,18 +28,19 @@ export function formatResult(result) {
28
28
  return lines.join('\n');
29
29
  }
30
30
 
31
- export function printSummary(results) {
31
+ export function printSummary(results, skipped = 0) {
32
32
  const passed = results.filter(r => r.passed).length;
33
33
  const failed = results.filter(r => !r.passed).length;
34
34
  const totalErrors = results.reduce((n, r) => n + r.errors.length, 0);
35
35
  const totalWarns = results.reduce((n, r) => n + r.warnings.length, 0);
36
36
  const totalMs = results.reduce((n, r) => n + r.duration, 0);
37
- const avgMs = Math.round(totalMs / results.length);
37
+ const avgMs = results.length > 0 ? Math.round(totalMs / results.length) : 0;
38
38
 
39
39
  log('\n' + '='.repeat(60));
40
40
  log('Validation Summary', 'cyan');
41
41
  log('='.repeat(60));
42
42
  log(`Patterns : ${results.length}`);
43
+ if (skipped > 0) log(`Skipped : ${skipped} (cached)`, 'gray');
43
44
  log(`Passed : ${passed}`, passed > 0 ? 'green' : 'gray');
44
45
  log(`Failed : ${failed}`, failed > 0 ? 'red' : 'gray');
45
46
  log(`Errors : ${totalErrors}`, totalErrors > 0 ? 'red' : 'gray');
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
@@ -2,6 +2,7 @@ import { chromium } from 'playwright';
2
2
  import PQueue from 'p-queue';
3
3
  import path from 'path';
4
4
  import fs from 'fs';
5
+ import crypto from 'crypto';
5
6
  import { parseArgs, resolveFiles } from './args.js';
6
7
  import { loginToWordPress } from './login.js';
7
8
  import {
@@ -14,15 +15,78 @@ import {
14
15
  import { checkBlockValidation, compareContent } from './validation.js';
15
16
  import { log, formatResult, printSummary } from './format.js';
16
17
 
18
+ const CACHE_FILE = '.sentinel-cache.json';
19
+
20
+ function loadCache() {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(path.join(process.cwd(), CACHE_FILE), 'utf8'));
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ async function saveCache(cache) {
29
+ try {
30
+ await fs.promises.writeFile(
31
+ path.join(process.cwd(), CACHE_FILE),
32
+ JSON.stringify(cache, null, 2)
33
+ );
34
+ } catch { /* ignore */ }
35
+ }
36
+
37
+ function hashContent(content) {
38
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
39
+ }
40
+
17
41
  export async function main() {
18
42
  const options = await parseArgs(process.argv.slice(2));
19
- const files = resolveFiles(options.files);
43
+
44
+ if (options.clearCache) {
45
+ try {
46
+ fs.unlinkSync(path.join(process.cwd(), CACHE_FILE));
47
+ log('Cache cleared.', 'green');
48
+ } catch {
49
+ log('No cache file found.', 'gray');
50
+ }
51
+ if (options.files.length === 0) process.exit(0);
52
+ }
53
+
54
+ let files = resolveFiles(options.files);
20
55
 
21
56
  if (files.length === 0) {
22
57
  log('No pattern files found.', 'red');
23
58
  process.exit(1);
24
59
  }
25
60
 
61
+ // Skip patterns that previously passed with the same file content.
62
+ const cache = options.cache ? loadCache() : {};
63
+ const skipped = [];
64
+ if (options.cache) {
65
+ const pending = [];
66
+ for (const file of files) {
67
+ try {
68
+ const content = fs.readFileSync(file, 'utf8');
69
+ const key = path.relative(process.cwd(), file);
70
+ if (cache[key]?.passed && cache[key]?.hash === hashContent(content)) {
71
+ skipped.push(file);
72
+ } else {
73
+ pending.push(file);
74
+ }
75
+ } catch {
76
+ pending.push(file);
77
+ }
78
+ }
79
+ files = pending;
80
+ if (skipped.length > 0) {
81
+ log(`Skipping ${skipped.length} previously-passed pattern(s) (cached)`, 'gray');
82
+ }
83
+ }
84
+
85
+ if (files.length === 0) {
86
+ log('All patterns already validated — nothing to do.', 'green');
87
+ process.exit(0);
88
+ }
89
+
26
90
  log(`\nFound ${files.length} pattern(s) — concurrency: ${options.concurrency}`, 'cyan');
27
91
 
28
92
  const browser = await chromium.launch({
@@ -60,10 +124,17 @@ export async function main() {
60
124
  const queue = new PQueue({ concurrency: options.concurrency });
61
125
  let idx = 0;
62
126
 
63
- // Push ALL tasks without awaiting — this is what makes workers run in parallel
127
+ // Push ALL tasks without awaiting — workers run in parallel.
128
+ // Print each result immediately as it finishes (real-time feedback).
64
129
  const promises = files.map(file => {
65
130
  const context = contexts[idx++ % options.concurrency];
66
- return queue.add(() => validatePatternFile(file, options, context));
131
+ return queue.add(async () => {
132
+ const result = await validatePatternFile(file, options, context);
133
+ if (!options.json) {
134
+ console.log(formatResult(result));
135
+ }
136
+ return result;
137
+ });
67
138
  });
68
139
 
69
140
  const results = await Promise.all(promises);
@@ -73,12 +144,32 @@ export async function main() {
73
144
 
74
145
  if (options.json) {
75
146
  for (const r of results) console.log(JSON.stringify(r));
76
- } else {
77
- for (const r of results) console.log(formatResult(r));
78
- printSummary(results);
79
147
  }
80
148
 
81
- process.exit(results.some(r => !r.passed) ? 1 : 0);
149
+ printSummary(results, skipped.length);
150
+
151
+ // Update cache with results from this run.
152
+ if (options.cache) {
153
+ for (const result of results) {
154
+ const key = path.relative(process.cwd(), result.patternPath);
155
+ if (result.passed) {
156
+ cache[key] = { hash: result.hash, passed: true, checkedAt: new Date().toISOString() };
157
+ } else {
158
+ delete cache[key];
159
+ }
160
+ }
161
+ await saveCache(cache);
162
+ log(` Cache updated → ${CACHE_FILE}`, 'gray');
163
+ }
164
+
165
+ // Write a log file on failure, or always when --log is set.
166
+ const hasFailed = results.some(r => !r.passed);
167
+ if (hasFailed || options.log) {
168
+ const logPath = await writeLogFile(results);
169
+ if (logPath) log(` Log saved → ${logPath}`, 'gray');
170
+ }
171
+
172
+ process.exit(hasFailed ? 1 : 0);
82
173
  }
83
174
 
84
175
  async function validatePatternFile(patternPath, options, context) {
@@ -91,12 +182,12 @@ async function validatePatternFile(patternPath, options, context) {
91
182
  try {
92
183
  fileContent = await fs.promises.readFile(patternPath, 'utf8');
93
184
  } catch (error) {
94
- return fail(patternName, startTime, 'file_error', error.message);
185
+ return fail(patternName, patternPath, startTime, 'file_error', error.message);
95
186
  }
96
187
 
97
188
  const blockContent = extractBlockContent(fileContent);
98
189
  if (!blockContent) {
99
- return fail(patternName, startTime, 'extraction_error', 'Could not extract block content from file');
190
+ return fail(patternName, patternPath, startTime, 'extraction_error', 'Could not extract block content from file');
100
191
  }
101
192
 
102
193
  const page = await context.newPage();
@@ -105,12 +196,12 @@ async function validatePatternFile(patternPath, options, context) {
105
196
  try {
106
197
  const pageId = await createDraftPage(page, options.adminUrl);
107
198
  if (pageId === null) {
108
- return fail(patternName, startTime, 'page_creation_error', 'Failed to create test page');
199
+ return fail(patternName, patternPath, startTime, 'page_creation_error', 'Failed to create test page');
109
200
  }
110
201
 
111
202
  if (!(await insertPatternIntoEditor(page, blockContent))) {
112
203
  await deletePage(page, options.adminUrl, pageId);
113
- return fail(patternName, startTime, 'insertion_error', 'Failed to insert pattern into editor');
204
+ return fail(patternName, patternPath, startTime, 'insertion_error', 'Failed to insert pattern into editor');
114
205
  }
115
206
 
116
207
  const saveResult = await savePage(page);
@@ -133,6 +224,8 @@ async function validatePatternFile(patternPath, options, context) {
133
224
 
134
225
  return {
135
226
  pattern: patternName,
227
+ patternPath,
228
+ hash: hashContent(fileContent),
136
229
  passed: saveResult.success && comparison.matches && blockErrors.length === 0,
137
230
  errors: saveResult.errors,
138
231
  warnings: saveResult.warnings,
@@ -142,18 +235,42 @@ async function validatePatternFile(patternPath, options, context) {
142
235
 
143
236
  } catch (error) {
144
237
  log(`Unexpected error — ${patternName}: ${error.message}`, 'red');
145
- return fail(patternName, startTime, 'validation_error', error.message);
238
+ return fail(patternName, patternPath, startTime, 'validation_error', error.message);
146
239
  } finally {
147
240
  await page.close();
148
241
  }
149
242
  }
150
243
 
151
- function fail(pattern, startTime, type, message) {
244
+ function fail(pattern, patternPath, startTime, type, message) {
152
245
  return {
153
246
  pattern,
247
+ patternPath,
248
+ hash: null,
154
249
  passed: false,
155
250
  errors: [{ type, message }],
156
251
  warnings: [],
157
252
  duration: Date.now() - startTime,
158
253
  };
159
254
  }
255
+
256
+ async function writeLogFile(results) {
257
+ try {
258
+ const now = new Date();
259
+ const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
260
+ const logPath = path.join(process.cwd(), `sentinel-${ts}.log.json`);
261
+ const payload = {
262
+ timestamp: now.toISOString(),
263
+ results: results.map(r => ({
264
+ pattern: r.pattern,
265
+ passed: r.passed,
266
+ duration: r.duration,
267
+ errors: r.errors,
268
+ warnings: r.warnings,
269
+ })),
270
+ };
271
+ await fs.promises.writeFile(logPath, JSON.stringify(payload, null, 2));
272
+ return logPath;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }