@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 +57 -1
- package/package.json +1 -1
- package/src/args.js +7 -0
- package/src/format.js +3 -2
- package/src/login.js +52 -29
- package/src/main.js +130 -13
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
|
|
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
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
}
|
|
37
|
-
|
|
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
|
-
|
|
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 —
|
|
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(() =>
|
|
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
|
-
|
|
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
|
+
}
|