backend-manager 5.2.9 → 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 +12 -0
- package/CLAUDE.md +1 -1
- package/docs/admin-post-route.md +29 -7
- package/package.json +10 -9
- package/src/manager/libraries/email/data/disposable-domains.json +1 -0
- package/src/manager/routes/admin/post/post.js +50 -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,18 @@ 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
|
+
|
|
17
29
|
# [5.2.9] - 2026-05-25
|
|
18
30
|
|
|
19
31
|
### Added
|
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
|
}
|
|
@@ -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
|
+
|
|
@@ -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
|
+
};
|