@imwz/wp-pattern-sentinel 1.1.3 → 1.1.5
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 +7 -1
- package/package.json +1 -1
- package/src/editor.js +83 -33
- package/src/format.js +12 -0
- package/src/main.js +9 -3
- package/src/validation.js +52 -21
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)) |
|
|
@@ -212,6 +212,10 @@ node -e "
|
|
|
212
212
|
|
|
213
213
|
`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.
|
|
214
214
|
|
|
215
|
+
A block can also pass validation only because one of its block type's deprecations accepted the markup and migrated it. The editor reports such a block as valid, but it saves different markup, and it shows as broken the next time the page is opened. Sentinel re-validates every parsed block against its original markup and reports these as `block_validation` errors reading "Block was migrated by a deprecation and will save different markup". A common cause is an attribute the block does not support, such as `aria-hidden` on `core/paragraph`.
|
|
216
|
+
|
|
217
|
+
`--cache` entries record the Sentinel version that passed them, so upgrading Sentinel re-validates every pattern once.
|
|
218
|
+
|
|
215
219
|
## npm publish
|
|
216
220
|
|
|
217
221
|
When ready to publish:
|
|
@@ -231,3 +235,5 @@ npx wp-pattern-sentinel --url=http://imagewize.test --user=admin --pass=secret p
|
|
|
231
235
|
|
|
232
236
|
- `0` — all patterns passed
|
|
233
237
|
- `1` — one or more patterns failed
|
|
238
|
+
|
|
239
|
+
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
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
);
|
|
92
|
-
|
|
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(
|
|
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
|
-
//
|
|
134
|
-
|
|
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(
|
|
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(
|
|
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
|
@@ -17,6 +17,12 @@ import { log, formatResult, printSummary } from './format.js';
|
|
|
17
17
|
|
|
18
18
|
const CACHE_FILE = '.sentinel-cache.json';
|
|
19
19
|
|
|
20
|
+
// Cache entries record the Sentinel version that passed them, so a release
|
|
21
|
+
// that adds or tightens a check re-validates patterns that passed before it.
|
|
22
|
+
const SENTINEL_VERSION = JSON.parse(
|
|
23
|
+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')
|
|
24
|
+
).version;
|
|
25
|
+
|
|
20
26
|
function loadCache() {
|
|
21
27
|
try {
|
|
22
28
|
return JSON.parse(fs.readFileSync(path.join(process.cwd(), CACHE_FILE), 'utf8'));
|
|
@@ -67,7 +73,7 @@ export async function main() {
|
|
|
67
73
|
try {
|
|
68
74
|
const content = fs.readFileSync(file, 'utf8');
|
|
69
75
|
const key = path.relative(process.cwd(), file);
|
|
70
|
-
if (cache[key]?.passed && cache[key]?.hash === hashContent(content)) {
|
|
76
|
+
if (cache[key]?.passed && cache[key]?.hash === hashContent(content) && cache[key]?.version === SENTINEL_VERSION) {
|
|
71
77
|
skipped.push(file);
|
|
72
78
|
} else {
|
|
73
79
|
pending.push(file);
|
|
@@ -153,7 +159,7 @@ export async function main() {
|
|
|
153
159
|
for (const result of results) {
|
|
154
160
|
const key = path.relative(process.cwd(), result.patternPath);
|
|
155
161
|
if (result.passed) {
|
|
156
|
-
cache[key] = { hash: result.hash, passed: true, checkedAt: new Date().toISOString() };
|
|
162
|
+
cache[key] = { hash: result.hash, passed: true, version: SENTINEL_VERSION, checkedAt: new Date().toISOString() };
|
|
157
163
|
} else {
|
|
158
164
|
delete cache[key];
|
|
159
165
|
}
|
|
@@ -201,7 +207,7 @@ async function validatePatternFile(patternPath, options, context) {
|
|
|
201
207
|
try {
|
|
202
208
|
const pageId = await createDraftPage(page, options.adminUrl, verbose);
|
|
203
209
|
if (pageId === null) {
|
|
204
|
-
return fail(patternName, patternPath, startTime, 'page_creation_error', '
|
|
210
|
+
return fail(patternName, patternPath, startTime, 'page_creation_error', 'Editor did not load after retries — not a pattern problem; re-run or lower --concurrency');
|
|
205
211
|
}
|
|
206
212
|
|
|
207
213
|
const refErrors = await checkPatternRefs(page, slugs, verbose);
|
package/src/validation.js
CHANGED
|
@@ -1,32 +1,62 @@
|
|
|
1
1
|
import { log } from './format.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Walk the block tree and collect any blocks where isValid === false
|
|
4
|
+
* Walk the block tree and collect any blocks where isValid === false, plus any
|
|
5
|
+
* block a deprecation silently migrated.
|
|
6
|
+
*
|
|
7
|
+
* When a block fails validation, the parser tries the block type's
|
|
8
|
+
* deprecations; if one accepts the markup, the block is migrated and reported
|
|
9
|
+
* as isValid: true with no issues, yet its attributes have changed and it will
|
|
10
|
+
* save different markup. The editor then shows the block as broken on the next
|
|
11
|
+
* load. Example: core/paragraph with aria-hidden="true" is taken by an old
|
|
12
|
+
* paragraph deprecation that drops fontFamily and keeps the whole <p> as the
|
|
13
|
+
* block's text, so the first save writes a <p> nested inside another <p>.
|
|
14
|
+
* Re-running validateBlock() on the parsed block compares what it will save
|
|
15
|
+
* against the original markup, which catches this.
|
|
5
16
|
*/
|
|
6
17
|
export async function checkBlockValidation(page, verbose = false) {
|
|
18
|
+
const start = Date.now();
|
|
7
19
|
if (verbose) log(' → Checking block validation...', 'gray');
|
|
8
20
|
try {
|
|
9
21
|
const errors = await page.evaluate(() => {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
const formatIssues = issues => (issues ?? []).map(issue => {
|
|
23
|
+
try {
|
|
24
|
+
return (issue.args ?? [])
|
|
25
|
+
.map(a => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
26
|
+
.join(' ');
|
|
27
|
+
} catch { return 'unknown issue'; }
|
|
28
|
+
});
|
|
29
|
+
const validateBlock = window.wp.blocks.validateBlock;
|
|
30
|
+
const migratedIssues = block => {
|
|
31
|
+
if (!validateBlock || block.isValid !== true || block.name === 'core/missing') return null;
|
|
32
|
+
const [isValid, issues] = validateBlock(block);
|
|
33
|
+
return isValid ? null : issues;
|
|
34
|
+
};
|
|
35
|
+
const walk = blocks => blocks.flatMap(block => {
|
|
36
|
+
const migrated = migratedIssues(block);
|
|
37
|
+
return [
|
|
38
|
+
...(block.isValid === false
|
|
39
|
+
? [{
|
|
40
|
+
blockId: block.clientId,
|
|
41
|
+
blockName: block.name,
|
|
42
|
+
error: 'Block validation failed',
|
|
43
|
+
validationIssues: formatIssues(block.validationIssues),
|
|
44
|
+
}]
|
|
45
|
+
: []),
|
|
46
|
+
...(migrated
|
|
47
|
+
? [{
|
|
48
|
+
blockId: block.clientId,
|
|
49
|
+
blockName: block.name,
|
|
50
|
+
error: 'Block was migrated by a deprecation and will save different markup',
|
|
51
|
+
validationIssues: formatIssues(migrated),
|
|
52
|
+
}]
|
|
53
|
+
: []),
|
|
54
|
+
...walk(block.innerBlocks ?? []),
|
|
55
|
+
];
|
|
56
|
+
});
|
|
27
57
|
return walk(window.wp.data.select('core/block-editor').getBlocks());
|
|
28
58
|
});
|
|
29
|
-
if (verbose) log(
|
|
59
|
+
if (verbose) log(` → Block validation complete (${Date.now() - start}ms)`, 'gray');
|
|
30
60
|
return errors;
|
|
31
61
|
} catch (error) {
|
|
32
62
|
log(`Block validation check error: ${error.message}`, 'yellow');
|
|
@@ -156,6 +186,7 @@ export async function checkPatternRefs(page, slugs, verbose = false) {
|
|
|
156
186
|
*/
|
|
157
187
|
export async function compareContent(page, originalContent, verbose = false) {
|
|
158
188
|
const result = { matches: true, errors: [], warnings: [], savedContent: null };
|
|
189
|
+
const start = Date.now();
|
|
159
190
|
|
|
160
191
|
try {
|
|
161
192
|
if (verbose) log(' → Comparing content...', 'gray');
|
|
@@ -166,7 +197,7 @@ export async function compareContent(page, originalContent, verbose = false) {
|
|
|
166
197
|
|
|
167
198
|
const normalize = str => str.replace(/\s+/g, ' ').trim();
|
|
168
199
|
if (normalize(normalizeForComparison(savedContent)) === normalize(normalizeForComparison(originalContent))) {
|
|
169
|
-
if (verbose) log(
|
|
200
|
+
if (verbose) log(` → Content matches (${Date.now() - start}ms)`, 'gray');
|
|
170
201
|
return result;
|
|
171
202
|
}
|
|
172
203
|
|
|
@@ -190,7 +221,7 @@ export async function compareContent(page, originalContent, verbose = false) {
|
|
|
190
221
|
message: `Content injected by editor:\n ${added.join('\n ')}`,
|
|
191
222
|
});
|
|
192
223
|
}
|
|
193
|
-
if (verbose) log(
|
|
224
|
+
if (verbose) log(` → Content comparison complete (${Date.now() - start}ms)`, 'gray');
|
|
194
225
|
} catch (error) {
|
|
195
226
|
result.errors.push({ type: 'comparison_error', message: error.message });
|
|
196
227
|
}
|