backend-manager 5.2.8 → 5.2.10
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/CHANGELOG.md +24 -0
- package/CLAUDE.md +1 -1
- package/docs/admin-post-route.md +29 -7
- package/package.json +10 -9
- package/src/manager/libraries/ai/index.js +1 -1
- package/src/manager/libraries/ai/providers/openai.js +92 -25
- package/src/manager/libraries/email/data/disposable-domains.json +1 -0
- package/src/manager/libraries/infer-contact.js +2 -2
- package/src/manager/routes/admin/post/post.js +50 -0
- package/test/helpers/ai-request-payload.js +300 -0
- package/test/routes/admin/create-post.js +54 -1
- package/test/routes/admin/post-resize-image.js +187 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.2.10] - 2026-05-26
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`POST /admin/post`: resize images at ingest.** Downloaded post images are now checked against `IMAGE_MAX_DIMENSION` (4096px on the long edge) in `src/manager/routes/admin/post/post.js` and re-encoded as progressive JPEG at `IMAGE_JPEG_QUALITY` (80) when oversized. Prevents downstream Jekyll/imagemin pipelines from stalling on huge sources (a real 16384×10576 source decoded to ~520MB raw and silently broke 4 StudyMonkey posts on production). `resizeImage`, `IMAGE_MAX_DIMENSION`, and `IMAGE_JPEG_QUALITY` are exported for tests. Adds `sharp` as a dependency.
|
|
22
|
+
- **`test/routes/admin/post-resize-image.js`** — 7 unit tests covering the resize contract (pass-through under the limit, boundary at exact limit, landscape/portrait/square scaling, the 16384×10576 case, on-disk overwrite). No network, no auth, no GitHub.
|
|
23
|
+
- **`test/routes/admin/create-post.js`**: extended `create-post-rewrites-body-images` to submit a 5000×3000 header image, plus new `verify-oversized-header-image-was-resized` step that fetches the committed image back from GitHub and asserts long edge ≤ 4096px.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Dependency bumps**: `sharp` ^0.34.4 → ^0.34.5, `sanitize-html` ^2.17.3 → ^2.17.4 (auto-bumped at install).
|
|
28
|
+
|
|
29
|
+
# [5.2.9] - 2026-05-25
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **OpenAI provider: multi-role prompt array support.** `options.prompt` now accepts either the legacy object form (`{ path|content, settings }`, auto-wrapped as a single `system`-role segment per the OpenAI Model Spec) OR an array form (`[{ role, path|content, settings }, ...]`) where each segment becomes its own message with its declared role. Valid roles: `system`, `developer`, `user`, `assistant`; order is preserved; role defaults to `system` if omitted; invalid roles throw. New internal helpers `normalizePrompt()` + `VALID_PROMPT_ROLES` canonicalize input; the request pipeline threads `promptSegments` end-to-end (per-segment load, per-role logging, per-segment error surfacing, `formatHistory` unshifts segments in declared order). `module.exports._internals` exposes `normalizePrompt`, `formatHistory`, `VALID_PROMPT_ROLES` for unit tests — not part of the public API. Backwards compatible: existing callers passing `prompt: { content: '...' }` are auto-wrapped as a single `system` segment with no consumer changes.
|
|
34
|
+
- **`test/helpers/ai-request-payload.js`** — 16 standalone tests covering the BEM → OpenAI payload transformation with no network and no assistant. Exercises `normalizePrompt` (undefined/null/empty handling, legacy object → single system segment, array-form role preservation, role-defaulting, invalid-role throw, full OpenAI Model Spec role coverage) and `formatHistory` (single-system emit, multi-segment order, empty-array → user-only, prompt+history+new-user interleaving, assistant `output_text` typing, history limit, `dedupeConsecutiveRoles` trailing-user drop, content trim/strip).
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Default OpenAI model bumped to `gpt-5.4-mini`** (was `gpt-5-mini`). Updated in `src/manager/libraries/ai/providers/openai.js` (`DEFAULT_MODEL`), `src/manager/libraries/ai/index.js` (usage example in JSDoc), and `src/manager/libraries/infer-contact.js` (`inferContactWithAI`). The pricing table in the OpenAI provider already includes `gpt-5.4-mini`, so no further config changes are required.
|
|
39
|
+
- **`inferContactWithAI` maxTokens doubled to 2048** (was 1024). Gives the model headroom for the richer multi-field contact response without truncation.
|
|
40
|
+
|
|
17
41
|
# [5.2.8] - 2026-05-25
|
|
18
42
|
|
|
19
43
|
### Changed
|
package/CLAUDE.md
CHANGED
|
@@ -119,7 +119,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
119
119
|
|
|
120
120
|
### Built-in Routes
|
|
121
121
|
|
|
122
|
-
- [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + `@post/` rewriting)
|
|
122
|
+
- [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + resize at ingest + `@post/` rewriting)
|
|
123
123
|
- [docs/payment-system.md](docs/payment-system.md) — full payment pipeline: Intent → Webhook → On-Write → Transition; subscription model, statuses, `resolveSubscription()`, transition handlers, processor interface, product config, test processor
|
|
124
124
|
- [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), template-owned schemas, asset hosting, seed campaigns
|
|
125
125
|
- [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), migration script template
|
package/docs/admin-post-route.md
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
# Admin Post Route
|
|
2
2
|
|
|
3
|
-
The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, upload, and body rewriting.
|
|
3
|
+
The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, resize, upload, and body rewriting.
|
|
4
4
|
|
|
5
5
|
## Image Processing Flow
|
|
6
6
|
|
|
7
7
|
1. Receives markdown body with external image URLs (e.g., ``)
|
|
8
8
|
2. Extracts all `` patterns from the body using regex
|
|
9
|
-
3. Downloads each image
|
|
10
|
-
4. **
|
|
11
|
-
5.
|
|
9
|
+
3. Downloads each image to a tmp dir
|
|
10
|
+
4. **Resizes** each image in place if its long edge exceeds `IMAGE_MAX_DIMENSION` (see below)
|
|
11
|
+
5. Commits all images to `src/assets/images/blog/post-{id}/` on GitHub (single commit via Git Trees API)
|
|
12
|
+
6. **Rewrites the body** to replace external URLs with `@post/{filename}` format
|
|
13
|
+
7. The `@post/` prefix is resolved at Jekyll build time by `jekyll-uj-powertools` to the full path
|
|
14
|
+
|
|
15
|
+
## Image resize
|
|
16
|
+
|
|
17
|
+
Sources from guest-post submissions can be enormous (16384×10576 has been seen in the wild — ~520MB raw RGB). Sources that large stall downstream Jekyll/imagemin pipelines on the consumer site, which silently ship the site missing those images.
|
|
18
|
+
|
|
19
|
+
To prevent this, every downloaded image is checked against `IMAGE_MAX_DIMENSION` (4096px on the long edge) and re-encoded as a progressive JPEG at `IMAGE_JPEG_QUALITY` (80) if it exceeds the limit. Images already at or below the limit pass through untouched.
|
|
20
|
+
|
|
21
|
+
The resize happens in `downloadImage()` (after the `.jpg` extension check, before returning to the caller), so:
|
|
22
|
+
- The base64 content that gets committed to GitHub is the resized version
|
|
23
|
+
- The consumer repo never sees the giant source
|
|
24
|
+
- Future downstream optimization (UJM imagemin, etc.) starts from a sane source size
|
|
25
|
+
|
|
26
|
+
Constants live in `src/manager/routes/admin/post/post.js`:
|
|
27
|
+
```js
|
|
28
|
+
const IMAGE_MAX_DIMENSION = 4096;
|
|
29
|
+
const IMAGE_JPEG_QUALITY = 80;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If these need to become configurable later, promote to `backend-manager-config.json` rather than env vars (deploy-environment-specific overrides aren't a real use case — the values are algorithm constants).
|
|
12
33
|
|
|
13
34
|
## Key Details
|
|
14
35
|
|
|
15
36
|
- Image filenames are derived from `hyphenate(alt_text)` + downloaded extension
|
|
37
|
+
- Only `.jpg` is accepted; other formats reject with a 400
|
|
16
38
|
- Header image (`headerImageURL`) is uploaded but NOT rewritten in the body (it's in frontmatter)
|
|
17
39
|
- Failed image downloads are skipped — the original external URL stays in the body
|
|
18
|
-
-
|
|
40
|
+
- All images + the post markdown are committed in a single commit via the Git Trees API
|
|
19
41
|
|
|
20
42
|
## Files
|
|
21
43
|
|
|
22
|
-
- `src/manager/routes/admin/post/post.js` — POST handler (create)
|
|
23
|
-
- `src/manager/routes/admin/post/put.js` — PUT handler (edit)
|
|
44
|
+
- `src/manager/routes/admin/post/post.js` — POST handler (create), includes `downloadImage()` + `resizeImage()` helpers
|
|
45
|
+
- `src/manager/routes/admin/post/put.js` — PUT handler (edit) — does NOT download images, just edits frontmatter/body in place
|
|
24
46
|
- `src/manager/routes/admin/post/templates/post.html` — Post template
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-manager",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.10",
|
|
4
4
|
"description": "Quick tools for developing Firebase functions",
|
|
5
5
|
"main": "src/manager/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -49,18 +49,18 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@anthropic-ai/claude-agent-sdk": "^0.
|
|
53
|
-
"@anthropic-ai/sdk": "^0.
|
|
52
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.152",
|
|
53
|
+
"@anthropic-ai/sdk": "^0.99.0",
|
|
54
54
|
"@firebase/rules-unit-testing": "^5.0.1",
|
|
55
55
|
"@google-cloud/firestore": "^7.11.6",
|
|
56
56
|
"@google-cloud/pubsub": "^5.3.0",
|
|
57
57
|
"@google-cloud/storage": "^7.19.0",
|
|
58
|
-
"@inquirer/prompts": "^8.
|
|
58
|
+
"@inquirer/prompts": "^8.5.0",
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
60
|
"@octokit/rest": "^22.0.1",
|
|
61
61
|
"@resvg/resvg-js": "^2.6.2",
|
|
62
62
|
"@sendgrid/mail": "^8.1.6",
|
|
63
|
-
"@sentry/node": "^10.
|
|
63
|
+
"@sentry/node": "^10.54.0",
|
|
64
64
|
"body-parser": "^2.2.2",
|
|
65
65
|
"busboy": "^1.6.0",
|
|
66
66
|
"chalk": "^5.6.2",
|
|
@@ -76,15 +76,16 @@
|
|
|
76
76
|
"lodash": "^4.18.1",
|
|
77
77
|
"lowdb": "^7.0.1",
|
|
78
78
|
"mailchimp-api-v3": "^1.15.0",
|
|
79
|
-
"markdown-it": "^14.
|
|
79
|
+
"markdown-it": "^14.2.0",
|
|
80
80
|
"mime-types": "^3.0.2",
|
|
81
|
-
"mjml": "^5.
|
|
81
|
+
"mjml": "^5.3.0",
|
|
82
82
|
"moment": "^2.30.1",
|
|
83
83
|
"nanoid": "^5.1.11",
|
|
84
84
|
"node-powertools": "^3.0.0",
|
|
85
85
|
"npm-api": "^1.0.1",
|
|
86
86
|
"pushid": "^1.0.0",
|
|
87
|
-
"sanitize-html": "^2.17.
|
|
87
|
+
"sanitize-html": "^2.17.4",
|
|
88
|
+
"sharp": "^0.34.5",
|
|
88
89
|
"stripe": "^22.1.1",
|
|
89
90
|
"uid-generator": "^2.0.0",
|
|
90
91
|
"uuid": "^14.0.0",
|
|
@@ -98,7 +99,7 @@
|
|
|
98
99
|
"prepare-package": "^2.1.0"
|
|
99
100
|
},
|
|
100
101
|
"peerDependencies": {
|
|
101
|
-
"firebase-admin": "^13.
|
|
102
|
+
"firebase-admin": "^13.10.0",
|
|
102
103
|
"firebase-functions": "^7.2.5"
|
|
103
104
|
}
|
|
104
105
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* const ai = Manager.AI(assistant);
|
|
6
|
-
* const result = await ai.request({ provider: 'openai', model: 'gpt-5-mini', ... });
|
|
6
|
+
* const result = await ai.request({ provider: 'openai', model: 'gpt-5.4-mini', ... });
|
|
7
7
|
* const result = await ai.request({ provider: 'anthropic', model: 'claude-sonnet-4-6', ... });
|
|
8
8
|
*
|
|
9
9
|
* Each provider returns { content, output, tokens, raw } with a consistent shape.
|
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const mimeTypes = require('mime-types');
|
|
8
8
|
|
|
9
9
|
// Constants
|
|
10
|
-
const DEFAULT_MODEL = 'gpt-5-mini';
|
|
10
|
+
const DEFAULT_MODEL = 'gpt-5.4-mini';
|
|
11
11
|
const MODERATION_MODEL = 'omni-moderation-latest';
|
|
12
12
|
|
|
13
13
|
// OpenAI model pricing table (per 1M tokens)
|
|
@@ -396,10 +396,25 @@ OpenAI.prototype.request = function (options) {
|
|
|
396
396
|
options.reasoning = options.reasoning || undefined;
|
|
397
397
|
|
|
398
398
|
// Format prompt
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
399
|
+
//
|
|
400
|
+
// Accepts two forms:
|
|
401
|
+
//
|
|
402
|
+
// 1) Object form (legacy / single-role):
|
|
403
|
+
// prompt: { path|content, settings }
|
|
404
|
+
// Auto-wrapped to a single 'system'-role segment per the OpenAI Model
|
|
405
|
+
// Spec — unlabeled prompts represent the platform's authoritative
|
|
406
|
+
// instruction.
|
|
407
|
+
//
|
|
408
|
+
// 2) Array form (multi-role):
|
|
409
|
+
// prompt: [
|
|
410
|
+
// { role: 'system', path|content, settings },
|
|
411
|
+
// { role: 'developer', path|content, settings },
|
|
412
|
+
// ...
|
|
413
|
+
// ]
|
|
414
|
+
// Each segment becomes its own message with the declared role. Order
|
|
415
|
+
// is preserved. Valid roles: 'system', 'developer', 'user',
|
|
416
|
+
// 'assistant'. Segments default to 'system' if role is omitted.
|
|
417
|
+
options.prompt = normalizePrompt(options.prompt);
|
|
403
418
|
|
|
404
419
|
// Format message
|
|
405
420
|
options.message = options.message || {};
|
|
@@ -428,19 +443,26 @@ OpenAI.prototype.request = function (options) {
|
|
|
428
443
|
_log('Starting', options);
|
|
429
444
|
|
|
430
445
|
|
|
431
|
-
// Load prompt
|
|
432
|
-
const
|
|
446
|
+
// Load prompt segments (one entry per role) and the user message
|
|
447
|
+
const promptSegments = options.prompt.map((segment) => ({
|
|
448
|
+
role: segment.role,
|
|
449
|
+
content: loadContent(segment, _log),
|
|
450
|
+
}));
|
|
433
451
|
const message = loadContent(options.message, _log);
|
|
434
452
|
const user = options.user?.auth?.uid || assistant.request.geolocation.ip || 'unknown';
|
|
435
453
|
|
|
436
454
|
// Log
|
|
437
|
-
|
|
455
|
+
for (const segment of promptSegments) {
|
|
456
|
+
_log(`Prompt[${segment.role}]`, segment.content);
|
|
457
|
+
}
|
|
438
458
|
_log('Message', message);
|
|
439
459
|
_log('User', user);
|
|
440
460
|
|
|
441
461
|
// Check for errors
|
|
442
|
-
|
|
443
|
-
|
|
462
|
+
for (const segment of promptSegments) {
|
|
463
|
+
if (segment.content instanceof Error) {
|
|
464
|
+
return reject(assistant.errorify(`Error loading prompt[${segment.role}]: ${segment.content}`, {code: 400}));
|
|
465
|
+
}
|
|
444
466
|
}
|
|
445
467
|
|
|
446
468
|
if (message instanceof Error) {
|
|
@@ -450,7 +472,7 @@ OpenAI.prototype.request = function (options) {
|
|
|
450
472
|
// Moderate if needed
|
|
451
473
|
let moderation = null;
|
|
452
474
|
if (options.moderate) {
|
|
453
|
-
moderation = await makeRequest('moderations', options, self,
|
|
475
|
+
moderation = await makeRequest('moderations', options, self, promptSegments, message, user, _log)
|
|
454
476
|
.then(async (r) => {
|
|
455
477
|
// {
|
|
456
478
|
// id: 'modr-8205',
|
|
@@ -481,7 +503,7 @@ OpenAI.prototype.request = function (options) {
|
|
|
481
503
|
|
|
482
504
|
|
|
483
505
|
// Make attempt
|
|
484
|
-
attemptRequest(options, self,
|
|
506
|
+
attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
|
|
485
507
|
});
|
|
486
508
|
}
|
|
487
509
|
|
|
@@ -493,6 +515,39 @@ function tryParse(content) {
|
|
|
493
515
|
}
|
|
494
516
|
}
|
|
495
517
|
|
|
518
|
+
// Roles permitted in the `options.prompt` array. Order is canonical per the
|
|
519
|
+
// OpenAI Model Spec authority hierarchy (system > developer > user > assistant).
|
|
520
|
+
const VALID_PROMPT_ROLES = new Set(['system', 'developer', 'user', 'assistant']);
|
|
521
|
+
|
|
522
|
+
// Normalize the `options.prompt` input into a canonical array of segments:
|
|
523
|
+
// [{ role, path, content, settings }, ...]
|
|
524
|
+
//
|
|
525
|
+
// Accepts:
|
|
526
|
+
// - undefined/null/empty → []
|
|
527
|
+
// - object: { path|content, settings } → wrapped as a single 'system' segment
|
|
528
|
+
// - array: [{ role, path|content, settings }, ...] → role defaults to 'system'
|
|
529
|
+
// if omitted; invalid roles throw.
|
|
530
|
+
function normalizePrompt(input) {
|
|
531
|
+
const segments = Array.isArray(input)
|
|
532
|
+
? input
|
|
533
|
+
: (input && (input.path || input.content || input.settings)) ? [input] : [];
|
|
534
|
+
|
|
535
|
+
return segments.map((segment) => {
|
|
536
|
+
const role = segment.role || 'system';
|
|
537
|
+
|
|
538
|
+
if (!VALID_PROMPT_ROLES.has(role)) {
|
|
539
|
+
throw new Error(`Invalid prompt role: ${role}. Valid roles: ${[...VALID_PROMPT_ROLES].join(', ')}`);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
role: role,
|
|
544
|
+
path: segment.path || '',
|
|
545
|
+
content: segment.content || '',
|
|
546
|
+
settings: segment.settings || {},
|
|
547
|
+
};
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
496
551
|
function loadContent(input, _log) {
|
|
497
552
|
// console.log('*** input!!!', input.content.slice(0, 50), input.path);
|
|
498
553
|
// console.log('*** input.content', input.content.slice(0, 50));
|
|
@@ -674,16 +729,20 @@ function formatMessageContent(content, attachments, _log, mode = 'responses', ro
|
|
|
674
729
|
}
|
|
675
730
|
|
|
676
731
|
|
|
677
|
-
function formatHistory(options,
|
|
732
|
+
function formatHistory(options, promptSegments, message, _log) {
|
|
678
733
|
// Get history with respect to the message limit
|
|
679
734
|
const history = options.history.messages.slice(-options.history.limit);
|
|
680
735
|
|
|
681
|
-
// Add prompt to beginning of history
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
736
|
+
// Add prompt segments to the beginning of history, in the order provided
|
|
737
|
+
// (each segment becomes its own message with the declared role)
|
|
738
|
+
for (let i = promptSegments.length - 1; i >= 0; i--) {
|
|
739
|
+
const segment = promptSegments[i];
|
|
740
|
+
history.unshift({
|
|
741
|
+
role: segment.role,
|
|
742
|
+
content: segment.content,
|
|
743
|
+
attachments: [],
|
|
744
|
+
});
|
|
745
|
+
}
|
|
687
746
|
|
|
688
747
|
// Get last history item
|
|
689
748
|
const lastHistory = history[history.length - 1];
|
|
@@ -722,7 +781,7 @@ function formatHistory(options, prompt, message, _log) {
|
|
|
722
781
|
return formatted;
|
|
723
782
|
}
|
|
724
783
|
|
|
725
|
-
function attemptRequest(options, self,
|
|
784
|
+
function attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log) {
|
|
726
785
|
const retries = options.retries;
|
|
727
786
|
const triggers = options.retryTriggers;
|
|
728
787
|
|
|
@@ -733,7 +792,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
|
|
|
733
792
|
_log(`Request ${attempt.count}/${retries}`);
|
|
734
793
|
|
|
735
794
|
// Request
|
|
736
|
-
makeRequest('responses', options, self,
|
|
795
|
+
makeRequest('responses', options, self, promptSegments, message, user, _log)
|
|
737
796
|
.then((r) => {
|
|
738
797
|
// Example
|
|
739
798
|
// {
|
|
@@ -834,7 +893,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
|
|
|
834
893
|
|
|
835
894
|
// Retry
|
|
836
895
|
if (attempt.count < retries && triggers.includes('parse')) {
|
|
837
|
-
return attemptRequest(options, self,
|
|
896
|
+
return attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
|
|
838
897
|
}
|
|
839
898
|
|
|
840
899
|
// Return
|
|
@@ -856,7 +915,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
|
|
|
856
915
|
|
|
857
916
|
// Retry
|
|
858
917
|
if (attempt.count < retries && triggers.includes('network')) {
|
|
859
|
-
return attemptRequest(options, self,
|
|
918
|
+
return attemptRequest(options, self, promptSegments, message, user, moderation, attempt, assistant, resolve, reject, _log);
|
|
860
919
|
}
|
|
861
920
|
|
|
862
921
|
// Return
|
|
@@ -864,7 +923,7 @@ function attemptRequest(options, self, prompt, message, user, moderation, attemp
|
|
|
864
923
|
});
|
|
865
924
|
}
|
|
866
925
|
|
|
867
|
-
function makeRequest(mode, options, self,
|
|
926
|
+
function makeRequest(mode, options, self, promptSegments, message, user, _log) {
|
|
868
927
|
return new Promise(async function(resolve, reject) {
|
|
869
928
|
const request = {
|
|
870
929
|
url: '',
|
|
@@ -895,7 +954,7 @@ function makeRequest(mode, options, self, prompt, message, user, _log) {
|
|
|
895
954
|
}
|
|
896
955
|
} else if (mode === 'responses') {
|
|
897
956
|
// Format history for responses API
|
|
898
|
-
const history = formatHistory(options,
|
|
957
|
+
const history = formatHistory(options, promptSegments, message, _log);
|
|
899
958
|
|
|
900
959
|
// Set request
|
|
901
960
|
request.url = 'https://api.openai.com/v1/responses';
|
|
@@ -1007,3 +1066,11 @@ function resolveReasoning(options) {
|
|
|
1007
1066
|
}
|
|
1008
1067
|
|
|
1009
1068
|
module.exports = OpenAI;
|
|
1069
|
+
|
|
1070
|
+
// Exposed for unit tests. Not part of the public API — do not rely on these
|
|
1071
|
+
// from consumer code.
|
|
1072
|
+
module.exports._internals = {
|
|
1073
|
+
normalizePrompt,
|
|
1074
|
+
formatHistory,
|
|
1075
|
+
VALID_PROMPT_ROLES,
|
|
1076
|
+
};
|
|
@@ -45,9 +45,9 @@ async function inferContactWithAI(email, assistant) {
|
|
|
45
45
|
try {
|
|
46
46
|
const ai = assistant.Manager.AI(assistant, process.env.BACKEND_MANAGER_OPENAI_API_KEY);
|
|
47
47
|
const result = await ai.request({
|
|
48
|
-
model: 'gpt-5-mini',
|
|
48
|
+
model: 'gpt-5.4-mini',
|
|
49
49
|
timeout: 60000,
|
|
50
|
-
maxTokens: 1024,
|
|
50
|
+
maxTokens: 1024 * 2,
|
|
51
51
|
moderate: false,
|
|
52
52
|
response: 'json',
|
|
53
53
|
prompt: {
|
|
@@ -17,6 +17,13 @@ const POST_TEMPLATE = jetpack.read(`${__dirname}/templates/post.html`);
|
|
|
17
17
|
const IMAGE_PATH_SRC = `src/assets/images/blog/post-{id}/`;
|
|
18
18
|
const IMAGE_REGEX = /(?:!\[(.*?)\]\((.*?)\))/img;
|
|
19
19
|
|
|
20
|
+
// Max dimension (px) for downloaded post images on the long edge, and JPEG
|
|
21
|
+
// re-encode quality. Sources above the max cause downstream Jekyll/imagemin
|
|
22
|
+
// pipelines to stall on huge decodes (e.g. a 16384×10576 source decodes to
|
|
23
|
+
// ~520MB raw), so resize at ingest time.
|
|
24
|
+
const IMAGE_MAX_DIMENSION = 4096;
|
|
25
|
+
const IMAGE_JPEG_QUALITY = 80;
|
|
26
|
+
|
|
20
27
|
module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
21
28
|
|
|
22
29
|
// Require authentication
|
|
@@ -218,9 +225,47 @@ async function downloadImage(assistant, src, alt) {
|
|
|
218
225
|
throw new Error(`Images must be .jpg (not ${result.ext})`);
|
|
219
226
|
}
|
|
220
227
|
|
|
228
|
+
// Resize in place if the long edge exceeds IMAGE_MAX_DIMENSION
|
|
229
|
+
await resizeImage(assistant, result.path);
|
|
230
|
+
|
|
221
231
|
return result;
|
|
222
232
|
}
|
|
223
233
|
|
|
234
|
+
// Helper: Resize image in place if the long edge exceeds IMAGE_MAX_DIMENSION.
|
|
235
|
+
// Re-encodes as progressive JPEG at IMAGE_JPEG_QUALITY. Short-circuits when the
|
|
236
|
+
// source is already within the limit.
|
|
237
|
+
async function resizeImage(assistant, filepath) {
|
|
238
|
+
const sharp = assistant.Manager.require('sharp');
|
|
239
|
+
|
|
240
|
+
const meta = await sharp(filepath).metadata();
|
|
241
|
+
const longEdge = Math.max(meta.width, meta.height);
|
|
242
|
+
|
|
243
|
+
if (longEdge <= IMAGE_MAX_DIMENSION) {
|
|
244
|
+
assistant.log(`resizeImage(): No resize needed (${meta.width}x${meta.height})`);
|
|
245
|
+
return { resized: false, width: meta.width, height: meta.height };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Resize to a buffer (cannot read+write the same path in one sharp pipeline)
|
|
249
|
+
const buffer = await sharp(filepath)
|
|
250
|
+
.resize({
|
|
251
|
+
width: IMAGE_MAX_DIMENSION,
|
|
252
|
+
height: IMAGE_MAX_DIMENSION,
|
|
253
|
+
fit: 'inside',
|
|
254
|
+
withoutEnlargement: true,
|
|
255
|
+
})
|
|
256
|
+
.jpeg({ quality: IMAGE_JPEG_QUALITY, progressive: true })
|
|
257
|
+
.toBuffer();
|
|
258
|
+
|
|
259
|
+
// Overwrite the file on disk
|
|
260
|
+
jetpack.write(filepath, buffer);
|
|
261
|
+
|
|
262
|
+
// Read the resized dimensions back for the log
|
|
263
|
+
const resizedMeta = await sharp(filepath).metadata();
|
|
264
|
+
assistant.log(`resizeImage(): Resized ${meta.width}x${meta.height} -> ${resizedMeta.width}x${resizedMeta.height} (max ${IMAGE_MAX_DIMENSION}px, q${IMAGE_JPEG_QUALITY})`);
|
|
265
|
+
|
|
266
|
+
return { resized: true, width: resizedMeta.width, height: resizedMeta.height };
|
|
267
|
+
}
|
|
268
|
+
|
|
224
269
|
// Helper: Commit all files (images + post) in a single commit using Git Trees API
|
|
225
270
|
async function commitAll(assistant, octokit, settings, files) {
|
|
226
271
|
const owner = settings.githubUser;
|
|
@@ -326,3 +371,8 @@ function formatClone(payload) {
|
|
|
326
371
|
return payload;
|
|
327
372
|
}
|
|
328
373
|
|
|
374
|
+
// Expose helpers + constants for tests
|
|
375
|
+
module.exports.resizeImage = resizeImage;
|
|
376
|
+
module.exports.IMAGE_MAX_DIMENSION = IMAGE_MAX_DIMENSION;
|
|
377
|
+
module.exports.IMAGE_JPEG_QUALITY = IMAGE_JPEG_QUALITY;
|
|
378
|
+
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: AI request payload shape (libraries/ai/providers/openai.js)
|
|
3
|
+
*
|
|
4
|
+
* Verifies the transformation from the BEM-facing `ai.request()` options
|
|
5
|
+
* (specifically `options.prompt` in either legacy object form or array form)
|
|
6
|
+
* into the eventual OpenAI HTTP payload (the `input: [...]` array).
|
|
7
|
+
*
|
|
8
|
+
* These tests exercise the pure helpers `normalizePrompt` and `formatHistory`
|
|
9
|
+
* directly — no network, no assistant required.
|
|
10
|
+
*/
|
|
11
|
+
const OpenAI = require('../../src/manager/libraries/ai/providers/openai.js');
|
|
12
|
+
const { normalizePrompt, formatHistory, VALID_PROMPT_ROLES } = OpenAI._internals;
|
|
13
|
+
|
|
14
|
+
function noopLog() {}
|
|
15
|
+
|
|
16
|
+
function baseOptions(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
dedupeConsecutiveRoles: true,
|
|
19
|
+
history: { messages: [], limit: 5 },
|
|
20
|
+
message: { attachments: [] },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
description: 'AI request payload shape (system/developer/user roles)',
|
|
27
|
+
type: 'group',
|
|
28
|
+
tests: [
|
|
29
|
+
// ─── normalizePrompt ───
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
name: 'normalize-undefined-returns-empty-array',
|
|
33
|
+
async run({ assert }) {
|
|
34
|
+
assert.deepEqual(normalizePrompt(undefined), [], 'undefined → []');
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
name: 'normalize-null-returns-empty-array',
|
|
40
|
+
async run({ assert }) {
|
|
41
|
+
assert.deepEqual(normalizePrompt(null), [], 'null → []');
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
name: 'normalize-empty-object-returns-empty-array',
|
|
47
|
+
async run({ assert }) {
|
|
48
|
+
assert.deepEqual(normalizePrompt({}), [], 'empty object → []');
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
name: 'normalize-legacy-object-form-wraps-as-system-segment',
|
|
54
|
+
async run({ assert }) {
|
|
55
|
+
const result = normalizePrompt({ path: '/tmp/example.md', settings: { foo: 'bar' } });
|
|
56
|
+
|
|
57
|
+
assert.equal(result.length, 1, 'one segment');
|
|
58
|
+
assert.equal(result[0].role, 'system', 'legacy object defaults to system role');
|
|
59
|
+
assert.equal(result[0].path, '/tmp/example.md', 'path preserved');
|
|
60
|
+
assert.deepEqual(result[0].settings, { foo: 'bar' }, 'settings preserved');
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
name: 'normalize-legacy-object-with-content-only',
|
|
66
|
+
async run({ assert }) {
|
|
67
|
+
const result = normalizePrompt({ content: 'inline prompt text' });
|
|
68
|
+
|
|
69
|
+
assert.equal(result.length, 1, 'one segment');
|
|
70
|
+
assert.equal(result[0].role, 'system', 'defaults to system');
|
|
71
|
+
assert.equal(result[0].content, 'inline prompt text', 'content preserved');
|
|
72
|
+
assert.equal(result[0].path, '', 'no path');
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
name: 'normalize-array-form-preserves-roles-and-order',
|
|
78
|
+
async run({ assert }) {
|
|
79
|
+
const result = normalizePrompt([
|
|
80
|
+
{ role: 'system', content: 'platform rules' },
|
|
81
|
+
{ role: 'developer', content: 'operator config' },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
assert.equal(result.length, 2, 'two segments');
|
|
85
|
+
assert.equal(result[0].role, 'system', 'first is system');
|
|
86
|
+
assert.equal(result[0].content, 'platform rules', 'first content');
|
|
87
|
+
assert.equal(result[1].role, 'developer', 'second is developer');
|
|
88
|
+
assert.equal(result[1].content, 'operator config', 'second content');
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
name: 'normalize-array-segment-without-role-defaults-to-system',
|
|
94
|
+
async run({ assert }) {
|
|
95
|
+
const result = normalizePrompt([
|
|
96
|
+
{ content: 'rule 1' },
|
|
97
|
+
{ role: 'developer', content: 'rule 2' },
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
assert.equal(result[0].role, 'system', 'missing role → system');
|
|
101
|
+
assert.equal(result[1].role, 'developer', 'explicit role preserved');
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
name: 'normalize-array-with-invalid-role-throws',
|
|
107
|
+
async run({ assert }) {
|
|
108
|
+
let threw = false;
|
|
109
|
+
try {
|
|
110
|
+
normalizePrompt([{ role: 'admin', content: 'bad' }]);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
threw = true;
|
|
113
|
+
assert.equal(
|
|
114
|
+
String(e.message).includes('Invalid prompt role'),
|
|
115
|
+
true,
|
|
116
|
+
'error mentions Invalid prompt role',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
assert.equal(threw, true, 'should throw on invalid role');
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
name: 'normalize-valid-roles-set-matches-openai-model-spec',
|
|
125
|
+
async run({ assert }) {
|
|
126
|
+
const expected = ['system', 'developer', 'user', 'assistant'];
|
|
127
|
+
const actual = [...VALID_PROMPT_ROLES].sort();
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(actual, expected.sort(), 'valid roles per OpenAI Model Spec');
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
name: 'normalize-all-valid-roles-accepted',
|
|
135
|
+
async run({ assert }) {
|
|
136
|
+
const segments = ['system', 'developer', 'user', 'assistant'].map((role) => ({
|
|
137
|
+
role,
|
|
138
|
+
content: `content for ${role}`,
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
const result = normalizePrompt(segments);
|
|
142
|
+
|
|
143
|
+
assert.equal(result.length, 4, 'all four segments accepted');
|
|
144
|
+
result.forEach((segment, i) => {
|
|
145
|
+
assert.equal(segment.role, segments[i].role, `segment ${i} role preserved`);
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// ─── formatHistory → OpenAI Responses API payload shape ───
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
name: 'format-single-system-prompt-emits-system-then-user',
|
|
154
|
+
async run({ assert }) {
|
|
155
|
+
const promptSegments = normalizePrompt({ content: 'You are a helpful assistant.' });
|
|
156
|
+
const formatted = formatHistory(baseOptions(), promptSegments, 'Hello!', noopLog);
|
|
157
|
+
|
|
158
|
+
assert.equal(formatted.length, 2, 'two messages: system + user');
|
|
159
|
+
assert.equal(formatted[0].role, 'system', 'first message is system');
|
|
160
|
+
assert.equal(formatted[0].content[0].type, 'input_text', 'system uses input_text');
|
|
161
|
+
assert.equal(formatted[0].content[0].text, 'You are a helpful assistant.', 'system text');
|
|
162
|
+
assert.equal(formatted[1].role, 'user', 'second message is user');
|
|
163
|
+
assert.equal(formatted[1].content[0].text, 'Hello!', 'user text');
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
name: 'format-system-plus-developer-emits-three-messages-in-order',
|
|
169
|
+
async run({ assert }) {
|
|
170
|
+
const promptSegments = normalizePrompt([
|
|
171
|
+
{ role: 'system', content: 'Platform rules go here.' },
|
|
172
|
+
{ role: 'developer', content: 'Operator config goes here.' },
|
|
173
|
+
]);
|
|
174
|
+
const formatted = formatHistory(baseOptions(), promptSegments, 'Customer email body.', noopLog);
|
|
175
|
+
|
|
176
|
+
assert.equal(formatted.length, 3, 'three messages');
|
|
177
|
+
assert.equal(formatted[0].role, 'system', 'order: system');
|
|
178
|
+
assert.equal(formatted[1].role, 'developer', 'order: developer');
|
|
179
|
+
assert.equal(formatted[2].role, 'user', 'order: user');
|
|
180
|
+
assert.equal(formatted[0].content[0].text, 'Platform rules go here.', 'system content');
|
|
181
|
+
assert.equal(formatted[1].content[0].text, 'Operator config goes here.', 'developer content');
|
|
182
|
+
assert.equal(formatted[2].content[0].text, 'Customer email body.', 'user content');
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
{
|
|
187
|
+
name: 'format-empty-prompt-array-emits-only-user-message',
|
|
188
|
+
async run({ assert }) {
|
|
189
|
+
const formatted = formatHistory(baseOptions(), [], 'Just a user message.', noopLog);
|
|
190
|
+
|
|
191
|
+
assert.equal(formatted.length, 1, 'only the user message');
|
|
192
|
+
assert.equal(formatted[0].role, 'user', 'role: user');
|
|
193
|
+
assert.equal(formatted[0].content[0].text, 'Just a user message.', 'text preserved');
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
name: 'format-interleaves-prompt-history-and-new-user-message',
|
|
199
|
+
async run({ assert }) {
|
|
200
|
+
const promptSegments = normalizePrompt([
|
|
201
|
+
{ role: 'system', content: 'system rules' },
|
|
202
|
+
{ role: 'developer', content: 'developer rules' },
|
|
203
|
+
]);
|
|
204
|
+
const options = baseOptions({
|
|
205
|
+
history: {
|
|
206
|
+
messages: [
|
|
207
|
+
{ role: 'user', content: 'first user msg' },
|
|
208
|
+
{ role: 'assistant', content: 'first ai reply' },
|
|
209
|
+
],
|
|
210
|
+
limit: 5,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
const formatted = formatHistory(options, promptSegments, 'second user msg', noopLog);
|
|
214
|
+
|
|
215
|
+
const roleSequence = formatted.map((m) => m.role);
|
|
216
|
+
assert.deepEqual(
|
|
217
|
+
roleSequence,
|
|
218
|
+
['system', 'developer', 'user', 'assistant', 'user'],
|
|
219
|
+
'prompts → history → new user message',
|
|
220
|
+
);
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
{
|
|
225
|
+
name: 'format-assistant-history-uses-output_text-type',
|
|
226
|
+
async run({ assert }) {
|
|
227
|
+
const options = baseOptions({
|
|
228
|
+
history: {
|
|
229
|
+
messages: [{ role: 'assistant', content: 'previous reply' }],
|
|
230
|
+
limit: 5,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
const formatted = formatHistory(options, [], 'new message', noopLog);
|
|
234
|
+
|
|
235
|
+
const assistantMsg = formatted.find((m) => m.role === 'assistant');
|
|
236
|
+
assert.equal(assistantMsg.content[0].type, 'output_text', 'assistant uses output_text');
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
name: 'format-respects-history-limit',
|
|
242
|
+
async run({ assert }) {
|
|
243
|
+
const options = baseOptions({
|
|
244
|
+
history: {
|
|
245
|
+
messages: [
|
|
246
|
+
{ role: 'user', content: 'msg 1' },
|
|
247
|
+
{ role: 'assistant', content: 'msg 2' },
|
|
248
|
+
{ role: 'user', content: 'msg 3' },
|
|
249
|
+
{ role: 'assistant', content: 'msg 4' },
|
|
250
|
+
{ role: 'user', content: 'msg 5' },
|
|
251
|
+
{ role: 'assistant', content: 'msg 6' },
|
|
252
|
+
],
|
|
253
|
+
limit: 2,
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const formatted = formatHistory(options, normalizePrompt({ content: 'sys' }), 'now', noopLog);
|
|
257
|
+
|
|
258
|
+
// Expected: 1 system + 2 history + 1 user = 4 messages
|
|
259
|
+
assert.equal(formatted.length, 4, 'system + 2 history + new user');
|
|
260
|
+
assert.equal(formatted[1].content[0].text, 'msg 5', 'second-to-last history kept');
|
|
261
|
+
assert.equal(formatted[2].content[0].text, 'msg 6', 'last history kept');
|
|
262
|
+
assert.equal(formatted[3].content[0].text, 'now', 'new user message appended');
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
{
|
|
267
|
+
name: 'format-dedupes-trailing-user-history-when-flag-set',
|
|
268
|
+
async run({ assert }) {
|
|
269
|
+
const options = baseOptions({
|
|
270
|
+
dedupeConsecutiveRoles: true,
|
|
271
|
+
history: {
|
|
272
|
+
messages: [
|
|
273
|
+
{ role: 'assistant', content: 'reply' },
|
|
274
|
+
{ role: 'user', content: 'should be dropped' },
|
|
275
|
+
],
|
|
276
|
+
limit: 5,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const formatted = formatHistory(options, [], 'real new message', noopLog);
|
|
280
|
+
|
|
281
|
+
// history's trailing 'user' is dropped, then the real new message is appended
|
|
282
|
+
assert.equal(formatted.length, 2, 'assistant + new user only');
|
|
283
|
+
assert.equal(formatted[0].role, 'assistant', 'kept assistant');
|
|
284
|
+
assert.equal(formatted[1].role, 'user', 'new user');
|
|
285
|
+
assert.equal(formatted[1].content[0].text, 'real new message', 'new user content');
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
{
|
|
290
|
+
name: 'format-strips-and-trims-content',
|
|
291
|
+
async run({ assert }) {
|
|
292
|
+
const promptSegments = normalizePrompt({ content: ' padded system content \n' });
|
|
293
|
+
const formatted = formatHistory(baseOptions(), promptSegments, ' padded user content ', noopLog);
|
|
294
|
+
|
|
295
|
+
assert.equal(formatted[0].content[0].text, 'padded system content', 'system trimmed');
|
|
296
|
+
assert.equal(formatted[1].content[0].text, 'padded user content', 'user trimmed');
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
};
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Requires admin/blogger role, GitHub API key, and repo_website config
|
|
6
6
|
*/
|
|
7
7
|
const { Octokit } = require('@octokit/rest');
|
|
8
|
+
const sharp = require('sharp');
|
|
9
|
+
|
|
10
|
+
const { IMAGE_MAX_DIMENSION } = require('../../../src/manager/routes/admin/post/post');
|
|
8
11
|
|
|
9
12
|
module.exports = {
|
|
10
13
|
description: 'Admin create post on GitHub',
|
|
@@ -130,12 +133,15 @@ module.exports = {
|
|
|
130
133
|
|
|
131
134
|
// Use a real public .jpg for the inline image
|
|
132
135
|
const inlineImageURL = 'https://picsum.photos/id/10/200/200.jpg';
|
|
136
|
+
// Oversized header image (long edge 5000px > IMAGE_MAX_DIMENSION 4096px) to
|
|
137
|
+
// verify the resize step runs end-to-end and the committed file is clamped.
|
|
138
|
+
const headerImageURL = 'https://picsum.photos/id/1/5000/3000.jpg';
|
|
133
139
|
|
|
134
140
|
const response = await http.post('admin/post', {
|
|
135
141
|
title: 'BEM Test Create Post',
|
|
136
142
|
url: 'bem-test-create-post',
|
|
137
143
|
description: 'Test post created by BEM test suite to verify @post/ body rewriting.',
|
|
138
|
-
headerImageURL:
|
|
144
|
+
headerImageURL: headerImageURL,
|
|
139
145
|
body: `# BEM Test Create Post\n\nSome intro text.\n\n\n\nMore text after the image.`,
|
|
140
146
|
postPath: 'guest',
|
|
141
147
|
});
|
|
@@ -186,6 +192,53 @@ module.exports = {
|
|
|
186
192
|
},
|
|
187
193
|
},
|
|
188
194
|
|
|
195
|
+
{
|
|
196
|
+
name: 'verify-oversized-header-image-was-resized',
|
|
197
|
+
auth: 'admin',
|
|
198
|
+
timeout: 30000,
|
|
199
|
+
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set' : false,
|
|
200
|
+
|
|
201
|
+
async run({ assert, state }) {
|
|
202
|
+
if (!state.postId) {
|
|
203
|
+
return; // Previous test didn't run
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
|
207
|
+
const imageDir = `src/assets/images/blog/post-${state.postId}/`;
|
|
208
|
+
|
|
209
|
+
// List committed images and pick the header (matches the slugified URL "bem-test-create-post")
|
|
210
|
+
const { data: dirData } = await octokit.rest.repos.getContent({
|
|
211
|
+
owner: state.owner,
|
|
212
|
+
repo: state.repo,
|
|
213
|
+
path: imageDir,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const headerFile = dirData.find((f) => f.name === 'bem-test-create-post.jpg');
|
|
217
|
+
assert.ok(headerFile, 'Header image should be committed at expected slug');
|
|
218
|
+
|
|
219
|
+
// Fetch the raw bytes and read dimensions
|
|
220
|
+
const { data: blob } = await octokit.rest.git.getBlob({
|
|
221
|
+
owner: state.owner,
|
|
222
|
+
repo: state.repo,
|
|
223
|
+
file_sha: headerFile.sha,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const buffer = Buffer.from(blob.content, 'base64');
|
|
227
|
+
const meta = await sharp(buffer).metadata();
|
|
228
|
+
|
|
229
|
+
// The source we requested was 5000x3000 (over the 4096 limit).
|
|
230
|
+
// After resize, the long edge should be clamped to IMAGE_MAX_DIMENSION.
|
|
231
|
+
const longEdge = Math.max(meta.width, meta.height);
|
|
232
|
+
assert.ok(
|
|
233
|
+
longEdge <= IMAGE_MAX_DIMENSION,
|
|
234
|
+
`Committed header image long edge (${meta.width}x${meta.height}) should be <= IMAGE_MAX_DIMENSION (${IMAGE_MAX_DIMENSION})`,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// And the resize should actually have happened (source was 5000x3000 → expect width=4096)
|
|
238
|
+
assert.equal(meta.width, IMAGE_MAX_DIMENSION, 'Resized width should equal IMAGE_MAX_DIMENSION');
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
|
|
189
242
|
// --- Auth rejection tests ---
|
|
190
243
|
|
|
191
244
|
{
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: routes/admin/post/post.resizeImage
|
|
3
|
+
* Unit tests for the in-place image resize used by the admin/post route.
|
|
4
|
+
*
|
|
5
|
+
* Run: npx mgr test routes/admin/post-resize-image
|
|
6
|
+
*
|
|
7
|
+
* Contract:
|
|
8
|
+
* - Images with both dimensions <= IMAGE_MAX_DIMENSION pass through untouched.
|
|
9
|
+
* - Images with either dimension > IMAGE_MAX_DIMENSION are resized in place,
|
|
10
|
+
* preserving aspect ratio, with the long edge clamped to IMAGE_MAX_DIMENSION.
|
|
11
|
+
* - Resize re-encodes as JPEG at IMAGE_JPEG_QUALITY (lossy is expected).
|
|
12
|
+
* - The file at the original path is overwritten with the resized bytes.
|
|
13
|
+
*/
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const jetpack = require('fs-jetpack');
|
|
17
|
+
const sharp = require('sharp');
|
|
18
|
+
|
|
19
|
+
const post = require('../../../src/manager/routes/admin/post/post');
|
|
20
|
+
|
|
21
|
+
const { resizeImage, IMAGE_MAX_DIMENSION, IMAGE_JPEG_QUALITY } = post;
|
|
22
|
+
|
|
23
|
+
// Generate a synthetic JPEG of the given dimensions and write it to a tmp path.
|
|
24
|
+
// Returns the absolute path on disk.
|
|
25
|
+
async function makeJpeg(width, height) {
|
|
26
|
+
const filepath = path.join(os.tmpdir(), `bem-test-resize-${Date.now()}-${width}x${height}.jpg`);
|
|
27
|
+
const buffer = await sharp({
|
|
28
|
+
create: {
|
|
29
|
+
width: width,
|
|
30
|
+
height: height,
|
|
31
|
+
channels: 3,
|
|
32
|
+
background: { r: 128, g: 128, b: 128 },
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
.jpeg({ quality: 90 })
|
|
36
|
+
.toBuffer();
|
|
37
|
+
|
|
38
|
+
jetpack.write(filepath, buffer);
|
|
39
|
+
return filepath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Minimal assistant stub — resizeImage only uses Manager.require + log.
|
|
43
|
+
function makeAssistant() {
|
|
44
|
+
return {
|
|
45
|
+
log: () => {},
|
|
46
|
+
Manager: {
|
|
47
|
+
require: (mod) => require(mod),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
description: 'routes/admin/post/post.resizeImage',
|
|
54
|
+
type: 'group',
|
|
55
|
+
|
|
56
|
+
tests: [
|
|
57
|
+
// ─── Constants exposed ───
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
name: 'exports-constants',
|
|
61
|
+
async run({ assert }) {
|
|
62
|
+
assert.equal(IMAGE_MAX_DIMENSION, 4096, 'IMAGE_MAX_DIMENSION should be 4096');
|
|
63
|
+
assert.equal(IMAGE_JPEG_QUALITY, 80, 'IMAGE_JPEG_QUALITY should be 80');
|
|
64
|
+
assert.isType(resizeImage, 'function', 'resizeImage should be exported as a function');
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// ─── Pass-through: image already within bounds ───
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
name: 'small-image-passes-through-untouched',
|
|
72
|
+
async run({ assert }) {
|
|
73
|
+
const filepath = await makeJpeg(800, 600);
|
|
74
|
+
const sizeBefore = jetpack.inspect(filepath).size;
|
|
75
|
+
|
|
76
|
+
const result = await resizeImage(makeAssistant(), filepath);
|
|
77
|
+
|
|
78
|
+
assert.equal(result.resized, false, 'Small image should not be resized');
|
|
79
|
+
assert.equal(result.width, 800, 'Width should be reported as-is');
|
|
80
|
+
assert.equal(result.height, 600, 'Height should be reported as-is');
|
|
81
|
+
|
|
82
|
+
const sizeAfter = jetpack.inspect(filepath).size;
|
|
83
|
+
assert.equal(sizeAfter, sizeBefore, 'File on disk should be byte-identical (no re-encode)');
|
|
84
|
+
|
|
85
|
+
jetpack.remove(filepath);
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
name: 'image-exactly-at-max-passes-through',
|
|
91
|
+
async run({ assert }) {
|
|
92
|
+
// Long edge exactly === IMAGE_MAX_DIMENSION → no resize (boundary condition)
|
|
93
|
+
const filepath = await makeJpeg(IMAGE_MAX_DIMENSION, 2000);
|
|
94
|
+
|
|
95
|
+
const result = await resizeImage(makeAssistant(), filepath);
|
|
96
|
+
|
|
97
|
+
assert.equal(result.resized, false, 'Image exactly at the limit should not be resized');
|
|
98
|
+
assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Width unchanged');
|
|
99
|
+
|
|
100
|
+
jetpack.remove(filepath);
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// ─── Resize: landscape (width > height) ───
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
name: 'landscape-oversized-is-resized-to-max-width',
|
|
108
|
+
async run({ assert }) {
|
|
109
|
+
// 8000x4000 landscape → long edge is width → clamp width to 4096, scale height proportionally to 2048
|
|
110
|
+
const filepath = await makeJpeg(8000, 4000);
|
|
111
|
+
|
|
112
|
+
const result = await resizeImage(makeAssistant(), filepath);
|
|
113
|
+
|
|
114
|
+
assert.equal(result.resized, true, 'Oversized landscape should be resized');
|
|
115
|
+
assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Width clamped to IMAGE_MAX_DIMENSION');
|
|
116
|
+
assert.equal(result.height, IMAGE_MAX_DIMENSION / 2, 'Height scaled proportionally (8000:4000 → 4096:2048)');
|
|
117
|
+
|
|
118
|
+
// Verify the file on disk was actually overwritten
|
|
119
|
+
const meta = await sharp(filepath).metadata();
|
|
120
|
+
assert.equal(meta.width, IMAGE_MAX_DIMENSION, 'On-disk width matches reported width');
|
|
121
|
+
assert.equal(meta.height, IMAGE_MAX_DIMENSION / 2, 'On-disk height matches reported height');
|
|
122
|
+
assert.equal(meta.format, 'jpeg', 'On-disk format is JPEG');
|
|
123
|
+
|
|
124
|
+
jetpack.remove(filepath);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// ─── Resize: portrait (height > width) ───
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
name: 'portrait-oversized-is-resized-to-max-height',
|
|
132
|
+
async run({ assert }) {
|
|
133
|
+
// 4000x8000 portrait → long edge is height → clamp height to 4096, width proportionally to 2048
|
|
134
|
+
const filepath = await makeJpeg(4000, 8000);
|
|
135
|
+
|
|
136
|
+
const result = await resizeImage(makeAssistant(), filepath);
|
|
137
|
+
|
|
138
|
+
assert.equal(result.resized, true, 'Oversized portrait should be resized');
|
|
139
|
+
assert.equal(result.width, IMAGE_MAX_DIMENSION / 2, 'Width scaled proportionally');
|
|
140
|
+
assert.equal(result.height, IMAGE_MAX_DIMENSION, 'Height clamped to IMAGE_MAX_DIMENSION');
|
|
141
|
+
|
|
142
|
+
jetpack.remove(filepath);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// ─── Resize: huge image (the bug we are guarding against) ───
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
name: 'huge-image-is-resized-down',
|
|
150
|
+
async run({ assert }) {
|
|
151
|
+
// 16384x10576 — the actual size from post-1779087609 (the-importance-of-feedback-loops).
|
|
152
|
+
// Raw RGB this size is ~520MB, which is what stalled UJM's imagemin stream.
|
|
153
|
+
const filepath = await makeJpeg(16384, 10576);
|
|
154
|
+
const sizeBefore = jetpack.inspect(filepath).size;
|
|
155
|
+
|
|
156
|
+
const result = await resizeImage(makeAssistant(), filepath);
|
|
157
|
+
|
|
158
|
+
assert.equal(result.resized, true, 'Huge image should be resized');
|
|
159
|
+
assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Long edge clamped to IMAGE_MAX_DIMENSION');
|
|
160
|
+
// 16384:10576 ratio → height = 4096 * (10576/16384) = 2644
|
|
161
|
+
assert.inRange(result.height, 2643, 2645, 'Height scaled proportionally (within rounding)');
|
|
162
|
+
|
|
163
|
+
const sizeAfter = jetpack.inspect(filepath).size;
|
|
164
|
+
assert.ok(sizeAfter < sizeBefore, 'Resized file on disk should be smaller than original');
|
|
165
|
+
|
|
166
|
+
jetpack.remove(filepath);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// ─── Square images ───
|
|
171
|
+
|
|
172
|
+
{
|
|
173
|
+
name: 'square-oversized-is-resized-to-square',
|
|
174
|
+
async run({ assert }) {
|
|
175
|
+
const filepath = await makeJpeg(8000, 8000);
|
|
176
|
+
|
|
177
|
+
const result = await resizeImage(makeAssistant(), filepath);
|
|
178
|
+
|
|
179
|
+
assert.equal(result.resized, true, 'Oversized square should be resized');
|
|
180
|
+
assert.equal(result.width, IMAGE_MAX_DIMENSION, 'Width clamped');
|
|
181
|
+
assert.equal(result.height, IMAGE_MAX_DIMENSION, 'Height clamped');
|
|
182
|
+
|
|
183
|
+
jetpack.remove(filepath);
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|