@imwz/wp-pattern-sentinel 0.2.6 → 1.0.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 +48 -1
- package/package.json +1 -1
- package/src/args.js +7 -0
- package/src/format.js +3 -2
- package/src/main.js +98 -11
- package/src/validation.js +12 -1
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
|
|
|
@@ -153,9 +156,53 @@ Credential rejections (wrong password) are not retried — only timeout errors t
|
|
|
153
156
|
|
|
154
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.
|
|
155
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
|
+
|
|
156
191
|
### Failure log
|
|
157
192
|
|
|
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.
|
|
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.
|
|
194
|
+
|
|
195
|
+
Failing results include a `savedContent` field — the editor's serialized output — so you can diff it directly against the source file:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
node -e "
|
|
199
|
+
const log = JSON.parse(require('fs').readFileSync('sentinel-*.log.json'));
|
|
200
|
+
const r = log.results.find(r => !r.passed);
|
|
201
|
+
console.log(r.savedContent);
|
|
202
|
+
" | diff - patterns/my-pattern.php
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`block_validation` errors also surface Gutenberg's human-readable issue messages (e.g. `"Expected attribute 'class' of value '…' but got '…'"`), so you no longer need to open the browser console to identify what failed.
|
|
159
206
|
|
|
160
207
|
## npm publish
|
|
161
208
|
|
package/package.json
CHANGED
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/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
|
-
|
|
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({
|
|
@@ -82,11 +146,25 @@ export async function main() {
|
|
|
82
146
|
for (const r of results) console.log(JSON.stringify(r));
|
|
83
147
|
}
|
|
84
148
|
|
|
85
|
-
printSummary(results);
|
|
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
|
+
}
|
|
86
164
|
|
|
87
|
-
// Write a log file
|
|
165
|
+
// Write a log file on failure, or always when --log is set.
|
|
88
166
|
const hasFailed = results.some(r => !r.passed);
|
|
89
|
-
if (hasFailed) {
|
|
167
|
+
if (hasFailed || options.log) {
|
|
90
168
|
const logPath = await writeLogFile(results);
|
|
91
169
|
if (logPath) log(` Log saved → ${logPath}`, 'gray');
|
|
92
170
|
}
|
|
@@ -104,12 +182,12 @@ async function validatePatternFile(patternPath, options, context) {
|
|
|
104
182
|
try {
|
|
105
183
|
fileContent = await fs.promises.readFile(patternPath, 'utf8');
|
|
106
184
|
} catch (error) {
|
|
107
|
-
return fail(patternName, startTime, 'file_error', error.message);
|
|
185
|
+
return fail(patternName, patternPath, startTime, 'file_error', error.message);
|
|
108
186
|
}
|
|
109
187
|
|
|
110
188
|
const blockContent = extractBlockContent(fileContent);
|
|
111
189
|
if (!blockContent) {
|
|
112
|
-
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');
|
|
113
191
|
}
|
|
114
192
|
|
|
115
193
|
const page = await context.newPage();
|
|
@@ -118,12 +196,12 @@ async function validatePatternFile(patternPath, options, context) {
|
|
|
118
196
|
try {
|
|
119
197
|
const pageId = await createDraftPage(page, options.adminUrl);
|
|
120
198
|
if (pageId === null) {
|
|
121
|
-
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');
|
|
122
200
|
}
|
|
123
201
|
|
|
124
202
|
if (!(await insertPatternIntoEditor(page, blockContent))) {
|
|
125
203
|
await deletePage(page, options.adminUrl, pageId);
|
|
126
|
-
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');
|
|
127
205
|
}
|
|
128
206
|
|
|
129
207
|
const saveResult = await savePage(page);
|
|
@@ -132,7 +210,11 @@ async function validatePatternFile(patternPath, options, context) {
|
|
|
132
210
|
if (blockErrors.length > 0) {
|
|
133
211
|
saveResult.errors.push(...blockErrors.map(e => ({
|
|
134
212
|
type: 'block_validation',
|
|
135
|
-
message: `${e.blockName} (${e.blockId}): ${e.error}
|
|
213
|
+
message: `${e.blockName} (${e.blockId}): ${e.error}${
|
|
214
|
+
e.validationIssues?.length
|
|
215
|
+
? '\n Issues: ' + e.validationIssues.join('\n ')
|
|
216
|
+
: ''
|
|
217
|
+
}`,
|
|
136
218
|
})));
|
|
137
219
|
}
|
|
138
220
|
|
|
@@ -146,6 +228,8 @@ async function validatePatternFile(patternPath, options, context) {
|
|
|
146
228
|
|
|
147
229
|
return {
|
|
148
230
|
pattern: patternName,
|
|
231
|
+
patternPath,
|
|
232
|
+
hash: hashContent(fileContent),
|
|
149
233
|
passed: saveResult.success && comparison.matches && blockErrors.length === 0,
|
|
150
234
|
errors: saveResult.errors,
|
|
151
235
|
warnings: saveResult.warnings,
|
|
@@ -155,15 +239,17 @@ async function validatePatternFile(patternPath, options, context) {
|
|
|
155
239
|
|
|
156
240
|
} catch (error) {
|
|
157
241
|
log(`Unexpected error — ${patternName}: ${error.message}`, 'red');
|
|
158
|
-
return fail(patternName, startTime, 'validation_error', error.message);
|
|
242
|
+
return fail(patternName, patternPath, startTime, 'validation_error', error.message);
|
|
159
243
|
} finally {
|
|
160
244
|
await page.close();
|
|
161
245
|
}
|
|
162
246
|
}
|
|
163
247
|
|
|
164
|
-
function fail(pattern, startTime, type, message) {
|
|
248
|
+
function fail(pattern, patternPath, startTime, type, message) {
|
|
165
249
|
return {
|
|
166
250
|
pattern,
|
|
251
|
+
patternPath,
|
|
252
|
+
hash: null,
|
|
167
253
|
passed: false,
|
|
168
254
|
errors: [{ type, message }],
|
|
169
255
|
warnings: [],
|
|
@@ -184,6 +270,7 @@ async function writeLogFile(results) {
|
|
|
184
270
|
duration: r.duration,
|
|
185
271
|
errors: r.errors,
|
|
186
272
|
warnings: r.warnings,
|
|
273
|
+
...(r.passed ? {} : { savedContent: r.savedContent ?? null }),
|
|
187
274
|
})),
|
|
188
275
|
};
|
|
189
276
|
await fs.promises.writeFile(logPath, JSON.stringify(payload, null, 2));
|
package/src/validation.js
CHANGED
|
@@ -8,7 +8,18 @@ export async function checkBlockValidation(page) {
|
|
|
8
8
|
return await page.evaluate(() => {
|
|
9
9
|
const walk = blocks => blocks.flatMap(block => [
|
|
10
10
|
...(block.isValid === false
|
|
11
|
-
? [{
|
|
11
|
+
? [{
|
|
12
|
+
blockId: block.clientId,
|
|
13
|
+
blockName: block.name,
|
|
14
|
+
error: 'Block validation failed',
|
|
15
|
+
validationIssues: (block.validationIssues ?? []).map(issue => {
|
|
16
|
+
try {
|
|
17
|
+
return (issue.args ?? [])
|
|
18
|
+
.map(a => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
19
|
+
.join(' ');
|
|
20
|
+
} catch { return 'unknown issue'; }
|
|
21
|
+
}),
|
|
22
|
+
}]
|
|
12
23
|
: []),
|
|
13
24
|
...walk(block.innerBlocks ?? []),
|
|
14
25
|
]);
|