diff --git a/assets/images/_fixtures/electrocat.png b/assets/images/_fixtures/electrocat.png
new file mode 100644
index 000000000000..01dd7621acd5
Binary files /dev/null and b/assets/images/_fixtures/electrocat.png differ
diff --git a/assets/images/_fixtures/screenshot.png b/assets/images/_fixtures/screenshot.png
new file mode 100644
index 000000000000..cac7a023ed74
Binary files /dev/null and b/assets/images/_fixtures/screenshot.png differ
diff --git a/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md b/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md
index b2d18757aa90..715d4a62001c 100644
--- a/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md
+++ b/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md
@@ -75,12 +75,6 @@ shortTitle: Export from your enterprise
```
Each time you add a new repository with an existing Migration GUID it will update the existing export. If you run `ghe-migrator add` again without a Migration GUID it will start a new export and generate a new Migration GUID. **Do not re-use the Migration GUID generated during an export when you start preparing your migration for import**.
-3. If you locked the source repository, you can use the `ghe-migrator target_url` command to set a custom lock message on the repository page that links to the repository's new location. Pass the source repository URL, the target repository URL, and the Migration GUID from Step 5:
-
- ```shell
- $ ghe-migrator target_url https://HOSTNAME/USERNAME/REPO-NAME https://TARGET-HOSTNAME/TARGET-USER-NAME/TARGET-REPO-NAME -g MIGRATION-GUID
- ```
-
6. To add more repositories to the same export, use the `ghe-migrator add` command with the `-g` flag. You'll pass in the new repository URL and the Migration GUID from Step 5:
```shell
$ ghe-migrator add https://HOSTNAME/USERNAME/OTHER-REPO-NAME -g MIGRATION-GUID --lock
diff --git a/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md b/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md
index ba602ec304c0..1fd2710bea3f 100644
--- a/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md
+++ b/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md
@@ -4,11 +4,11 @@
{%- ifversion ghes = 3.5 or ghes = 3.6 %}
-**Note**: The `http(s)://render.HOSTNAME` subdomain is deprecated in {% data variables.product.product_name %} 3.7 and later. After you upgrade to 3.7 or later, ensure that your TLS certificate covers the subdomains for the replacement services, `http(s)://notebook.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`.
+**Note**: The `http(s)://render.HOSTNAME` subdomain is deprecated in {% data variables.product.product_name %} 3.7 and later. After you upgrade to 3.7 or later, ensure that your TLS certificate covers the subdomains for the replacement services, `http(s)://notebooks.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`.
{%- elsif ghes = 3.7 or ghes = 3.8 %}
-**Note**: The `http(s)://notebook.HOSTNAME` or `http(s)://viewscreen.HOSTNAME` subdomains are new in {% data variables.product.product_name %} 3.7 and later, and replace `http(s)://render.HOSTNAME`. After you upgrade to 3.7 or later, your TLS certificate must cover the subdomain for the replacement services, `http(s)://notebook.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`.
+**Note**: The `http(s)://notebooks.HOSTNAME` or `http(s)://viewscreen.HOSTNAME` subdomains are new in {% data variables.product.product_name %} 3.7 and later, and replace `http(s)://render.HOSTNAME`. After you upgrade to 3.7 or later, your TLS certificate must cover the subdomain for the replacement services, `http(s)://notebooks.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`.
{%- endif %}
diff --git a/lib/render-content/create-processor.js b/lib/render-content/create-processor.js
index 21f968b6e971..f12806106f86 100644
--- a/lib/render-content/create-processor.js
+++ b/lib/render-content/create-processor.js
@@ -19,6 +19,7 @@ import remarkCodeExtra from 'remark-code-extra'
import codeHeader from './plugins/code-header.js'
import rewriteLocalLinks from './plugins/rewrite-local-links.js'
import rewriteImgSources from './plugins/rewrite-asset-urls.js'
+import rewriteAssetImgTags from './plugins/rewrite-asset-img-tags.js'
import useEnglishHeadings from './plugins/use-english-headings.js'
import wrapInElement from './plugins/wrap-in-element.js'
import doctocatLinkIcon from './doctocat-link-icon.js'
@@ -44,6 +45,7 @@ export default function createProcessor(context) {
.use(raw)
.use(wrapInElement, { selector: 'ol > li img', wrapper: 'span.procedural-image-wrapper' })
.use(rewriteImgSources)
+ .use(rewriteAssetImgTags)
.use(rewriteLocalLinks, context)
.use(html)
}
diff --git a/lib/render-content/plugins/rewrite-asset-img-tags.js b/lib/render-content/plugins/rewrite-asset-img-tags.js
new file mode 100644
index 000000000000..e17f5fb33a8b
--- /dev/null
+++ b/lib/render-content/plugins/rewrite-asset-img-tags.js
@@ -0,0 +1,125 @@
+import { visit, SKIP } from 'unist-util-visit'
+
+/**
+ * `structuredClone` was added in Node 17 and onwards.
+ * https://developer.mozilla.org/en-US/docs/Web/API/structuredClone#browser_compatibility
+ *
+ * At the time of writing, we use Node 18 in CI and in production, but
+ * someone might be previewing locally with an older version so
+ * let's make a quick polyfill.
+ * We could add a specific (`js-core`) package for this polyfill, but it's
+ * fortunately not necessary in this context because it's safe enough
+ * clone by turning into a string and back again.
+ */
+function structuredClonePolyfill(obj) {
+ if (typeof structuredClone !== 'undefined') {
+ return structuredClone(obj)
+ } else {
+ // Note, that this naive clone would turn Date objects into strings.
+ // So don't use this polyfill if certain values aren't primitives
+ // that JSON.parse can handle.
+ return JSON.parse(JSON.stringify(obj))
+ }
+}
+
+const SUPPORT_AVIF_ASSETS = Boolean(JSON.parse(process.env.SUPPORT_AVIF_ASSETS || 'false'))
+
+// This number must match a width we're willing to accept in a dynamic
+// asset URL.
+const MAX_WIDTH = 1000
+
+// Matches any tags with an href that starts with `/assets/`
+const matcher = (node) =>
+ node.type === 'element' &&
+ node.tagName === 'img' &&
+ node.properties &&
+ node.properties.src &&
+ node.properties.src.startsWith('/assets/')
+
+/**
+ * Where it can mutate the AST to swap from:
+ *
+ *
+ *
+ * To:
+ *
+ *
+ *
+ * []
+ *
+ *
+ *
+ * Note that the AVIF format is optional as it depends on the, off by
+ * default, `process.env.SUPPORT_AVIF_ASSETS`.
+ * */
+export default function rewriteAssetImgTags() {
+ return (tree) => {
+ visit(tree, matcher, (node) => {
+ if (node.properties.src.endsWith('.png')) {
+ const copyPNG = structuredClonePolyfill(node)
+
+ /**
+ * If AVIF is support, we consider it "better" by injecting it first.
+ * If the user agent supports both WebP and AVIF and it gets
+ * HTML that looks like this:
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * Then, browsers like Chrome will try the AVIF format first.
+ * Other browsers, that don't support AVIF will use WebP.
+ * */
+ if (SUPPORT_AVIF_ASSETS) {
+ const sourceAVIF = {
+ type: 'element',
+ tagName: 'source',
+ properties: {
+ srcset: injectMaxWidth(node.properties.src.replace(/\.png$/, '.avif'), MAX_WIDTH),
+ type: 'image/avif',
+ },
+ children: [],
+ }
+ node.children.push(sourceAVIF)
+ }
+
+ const sourceWEBP = {
+ type: 'element',
+ tagName: 'source',
+ properties: {
+ srcset: injectMaxWidth(node.properties.src.replace(/\.png$/, '.webp'), MAX_WIDTH),
+ type: 'image/webp',
+ },
+ children: [],
+ }
+ node.children.push(sourceWEBP)
+
+ node.children.push(copyPNG)
+ node.tagName = 'picture'
+ delete node.properties.alt
+ delete node.properties.src
+ // Don't go further or else you end up in an infinite recursion
+ return SKIP
+ }
+ })
+ }
+}
+
+/**
+ * Given a pathname, insert the `/_mw-DDDD/`.
+ *
+ * For example, if the pathname is `/assets/cb-1234/images/foo.png`
+ * return `/assets/cb-1234/_mw-1000/images/foo.png`
+ */
+function injectMaxWidth(pathname, maxWidth) {
+ const split = pathname.split('/')
+ // This prefix needs to match what's possibly expected in dynamic-assets.js
+ const inject = `mw-${maxWidth}`
+ if (split.includes(inject)) {
+ throw new Error(`pathname already includes '${inject}'`)
+ }
+ split.splice(3, 0, inject)
+ return split.join('/')
+}
diff --git a/middleware/dynamic-assets.js b/middleware/dynamic-assets.js
index 46ebccb0bc04..a5e84671335b 100644
--- a/middleware/dynamic-assets.js
+++ b/middleware/dynamic-assets.js
@@ -5,6 +5,35 @@ import sharp from 'sharp'
import { assetCacheControl, defaultCacheControl } from './cache-control.js'
import { setFastlySurrogateKey, SURROGATE_ENUMS } from './set-fastly-surrogate-key.js'
+/**
+ * This is the indicator that is a virtual part of the URL.
+ * Similar to `/cb-1234/` in asset URLs, it's just there to tell the
+ * middleware that the image can be aggressively cached. It's not
+ * part of the actual file-on-disk path.
+ * Similarly, `/mw-1000/` is virtual and will be observed and removed from
+ * the pathname before trying to look it up as disk-on-file.
+ * The exact pattern needs to match how it's set in whatever Markdown
+ * processing code that might make dynamic asset URLs.
+ * So if you change this, make sure you change the code that expects
+ * to be able to inject this into the URL.
+ */
+const maxWidthPathPartRegex = /\/mw-(\d+)\//
+/**
+ *
+ * Why not any free number? If we allowed it to be any integer number
+ * someone would put our backend servers at risk by doing something like:
+ *
+ * const makeURL = () => `${BASE}/assets/mw-${Math.floor(Math.random()*1000)}/foo.png`
+ * await Promise.all([...Array(10000).keys()].map(makeURL))
+ *
+ * Which would be lots of distinctly different and valid URLs that the
+ * CDN can never really "protect us" on because they're too often distinct.
+ *
+ * At the moment, the only business need is for 1,000 pixels, so the array
+ * only has one. But can change in the future and make this sentence moot.
+ */
+const VALID_MAX_WIDTHS = [1000]
+
export default async function dynamicAssets(req, res, next) {
if (!req.url.startsWith('/assets/')) return next()
@@ -44,27 +73,38 @@ export default async function dynamicAssets(req, res, next) {
return res.redirect(302, req.path)
}
+ // From PNG to WEBP, if the PNG exists
if (req.path.endsWith('.webp')) {
- // From PNG (if it exists) to WEBP
+ const { url, maxWidth, error } = deconstructImageURL(req.path)
+ if (error) {
+ return res.status(400).type('text/plain').send(error.toString())
+ }
try {
- const originalBuffer = await fs.readFile(req.path.slice(1).replace(/\.webp$/, '.png'))
- const buffer = await sharp(originalBuffer)
- // Note that by default, sharp will use a lossy compression.
- // (i.e. `{lossless: false}` in the options)
- // The difference is that a lossless image is slightly crisper
- // but becomes on average 1.8x larger.
- // Given how we serve images, no human would be able to tell the
- // difference simply by looking at the image as it appears as an
- // image tag in the web page.
- // Also given that rendering-for-viewing is the "end of the line"
- // for the image meaning it just ends up being viewed and not
- // resaved as a source file. If we had intention to overwrite all
- // original PNG source files to WEBP, we should consier lossless
- // to preserve as much quality as possible at the source level.
- // The default quality is 80% which, combined with `lossless:false`
- // makes our images 2.8x smaller than the average PNG.
- .webp()
- .toBuffer()
+ const originalBuffer = await fs.readFile(url.slice(1).replace(/\.webp$/, '.png'))
+ const image = sharp(originalBuffer)
+
+ if (maxWidth) {
+ const { width } = await image.metadata()
+ if (width > maxWidth) {
+ image.resize({ width: maxWidth })
+ }
+ }
+
+ // Note that by default, sharp will use a lossy compression.
+ // (i.e. `{lossless: false}` in the options)
+ // The difference is that a lossless image is slightly crisper
+ // but becomes on average 1.8x larger.
+ // Given how we serve images, no human would be able to tell the
+ // difference simply by looking at the image as it appears as an
+ // image tag in the web page.
+ // Also given that rendering-for-viewing is the "end of the line"
+ // for the image meaning it just ends up being viewed and not
+ // resaved as a source file. If we had intention to overwrite all
+ // original PNG source files to WEBP, we should consier lossless
+ // to preserve as much quality as possible at the source level.
+ // The default quality is 80% which, combined with `lossless:false`
+ // makes our images 2.8x smaller than the average PNG.
+ const buffer = await image.webp().toBuffer()
assetCacheControl(res)
return res.type('image/webp').send(buffer)
} catch (error) {
@@ -74,11 +114,23 @@ export default async function dynamicAssets(req, res, next) {
}
}
+ // From PNG to AVIF, if the PNG exists
if (req.path.endsWith('.avif')) {
- // From PNG (if it exists) to AVIF
+ const { url, maxWidth, error } = deconstructImageURL(req.path)
+ if (error) {
+ return res.status(400).type('text/plain').send(error.toString())
+ }
try {
- const originalBuffer = await fs.readFile(req.path.slice(1).replace(/\.avif$/, '.png'))
- const buffer = await sharp(originalBuffer)
+ const originalBuffer = await fs.readFile(url.slice(1).replace(/\.avif$/, '.png'))
+ const image = sharp(originalBuffer)
+
+ if (maxWidth) {
+ const { width } = await image.metadata()
+ if (width > maxWidth) {
+ image.resize({ width: maxWidth })
+ }
+ }
+ const buffer = await image
.avif({
// The default is 4 (max is 9). Because this is a dynamic thing
// and AVIF encoding is slow for large images, go for a smaller
@@ -114,3 +166,19 @@ export default async function dynamicAssets(req, res, next) {
// broken link, like it might be to a regular HTML page.
res.status(404).type('text/plain').send('Asset not found')
}
+
+function deconstructImageURL(url) {
+ let error
+ let maxWidth
+ const match = url.match(maxWidthPathPartRegex)
+ if (match) {
+ const [whole, number] = match
+ maxWidth = parseInt(number)
+ if (isNaN(maxWidth) || maxWidth <= 0 || !VALID_MAX_WIDTHS.includes(maxWidth)) {
+ error = new Error(`width number (${maxWidth}) is not a valid number`)
+ } else {
+ url = url.replace(whole, '/')
+ }
+ }
+ return { url, maxWidth, error }
+}
diff --git a/tests/fixtures/content/get-started/foo/index.md b/tests/fixtures/content/get-started/foo/index.md
index c1879b3b87a8..3d0213d0d4e5 100644
--- a/tests/fixtures/content/get-started/foo/index.md
+++ b/tests/fixtures/content/get-started/foo/index.md
@@ -12,4 +12,5 @@ children:
- /autotitling
- /typo-autotitling
- /cross-version-linking
+ - /single-image
---
diff --git a/tests/fixtures/content/get-started/foo/single-image.md b/tests/fixtures/content/get-started/foo/single-image.md
new file mode 100644
index 000000000000..e8676a84309d
--- /dev/null
+++ b/tests/fixtures/content/get-started/foo/single-image.md
@@ -0,0 +1,14 @@
+---
+title: Single image
+intro: A simple page that has 1 asset image
+versions:
+ fpt: '*'
+ ghes: '*'
+ ghae: '*'
+ ghec: '*'
+type: how_to
+---
+
+## An image
+
+
diff --git a/tests/helpers/e2etest.js b/tests/helpers/e2etest.js
index 6f36cb9db93a..7ed20e5eda5f 100644
--- a/tests/helpers/e2etest.js
+++ b/tests/helpers/e2etest.js
@@ -10,6 +10,7 @@ export async function get(
followRedirects: false,
followAllRedirects: false,
headers: {},
+ responseType: undefined,
}
) {
const method = opts.method || 'get'
@@ -23,6 +24,7 @@ export async function get(
retry: { limit: 0 },
throwHttpErrors: false,
followRedirect: opts.followAllRedirects || opts.followRedirects,
+ responseType: opts.responseType,
},
isUndefined
)
diff --git a/tests/rendering-fixtures/dynamic-assets.js b/tests/rendering-fixtures/dynamic-assets.js
new file mode 100644
index 000000000000..4fd6cca5fdea
--- /dev/null
+++ b/tests/rendering-fixtures/dynamic-assets.js
@@ -0,0 +1,124 @@
+import { jest } from '@jest/globals'
+import sharp from 'sharp'
+import { fileTypeFromBuffer } from 'file-type'
+
+import { SURROGATE_ENUMS } from '../../middleware/set-fastly-surrogate-key.js'
+import { get, head } from '../helpers/e2etest.js'
+
+const POSSIBLE_EXTENSIONS = ['webp', 'avif']
+
+const EXPECTED_MIMETYPES = {
+ webp: 'image/webp',
+ avif: 'image/avif',
+}
+
+describe('dynamic assets', () => {
+ jest.setTimeout(3 * 60 * 1000)
+
+ test.each(POSSIBLE_EXTENSIONS)('GET PNG as a %s', async (extension) => {
+ const res = await get(`/assets/images/_fixtures/screenshot.${extension}`, {
+ responseType: 'buffer',
+ })
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension])
+ const { mime } = await fileTypeFromBuffer(res.text)
+ if (extension === 'webp') {
+ expect(mime).toBe('image/webp')
+ } else if (extension === 'avif') {
+ expect(mime).toBe('image/avif')
+ } else {
+ throw new Error('unrecognized test')
+ }
+ })
+
+ test.each(POSSIBLE_EXTENSIONS)('HEAD PNG as a %s', async (extension) => {
+ const res = await head(`/assets/images/_fixtures/screenshot.${extension}`)
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension])
+ })
+
+ test.each(POSSIBLE_EXTENSIONS)('get PNG as a %s with cache busting prefix', async (extension) => {
+ const res = await get(`/assets/cb-12345/images/_fixtures/screenshot.${extension}`)
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['cache-control']).toContain('public')
+ expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/)
+ expect(res.headers['surrogate-key']).toBe(SURROGATE_ENUMS.MANUAL)
+ })
+
+ test.each(POSSIBLE_EXTENSIONS)('max-width=1000 as a %s', async (extension) => {
+ // The _fixtures/screenshot.png is 2000(x1494) which is *more than 1000*
+ const res = await get(`/assets/images/mw-1000/_fixtures/screenshot.${extension}`, {
+ responseType: 'buffer',
+ })
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension])
+ const image = sharp(res.text)
+ const { width, height } = await image.metadata()
+ expect(width).toBe(1000)
+ expect(height).toBe(747)
+ })
+
+ test.each(POSSIBLE_EXTENSIONS)('max-width not necessary as a %s', async (extension) => {
+ // The _fixtures/electrocat.png is 448(x448) which is *less than 1000*
+ const res = await get(`/assets/images/mw-1000/_fixtures/electrocat.${extension}`, {
+ responseType: 'buffer',
+ })
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension])
+ const image = sharp(res.text)
+ const { width, height } = await image.metadata()
+ expect(width).toBe(448)
+ expect(height).toBe(448)
+ })
+
+ test("can't set set max-width indicator on the PNG that is already on disk", async () => {
+ const res = await get(`/assets/images/mw-1000/_fixtures/screenshot.png`)
+ expect(res.statusCode).toBe(404)
+ })
+
+ test.each(POSSIBLE_EXTENSIONS)(
+ 'max-width has to be a valid number when converting to %s',
+ async (extension) => {
+ // 0 is too small
+ {
+ const res = await get(`/assets/images/mw-0/_fixtures/screenshot.${extension}`)
+ expect(res.statusCode).toBe(400)
+ expect(res.headers['content-type']).toMatch('text/plain')
+ expect(res.text).toMatch('Error: width number (0) is not a valid number')
+ }
+ // 1234 is not a number that is recognized
+ {
+ const res = await get(`/assets/images/mw-1234/_fixtures/screenshot.${extension}`)
+ expect(res.statusCode).toBe(400)
+ expect(res.headers['content-type']).toMatch('text/plain')
+ expect(res.text).toMatch('Error: width number (1234) is not a valid number')
+ }
+ }
+ )
+
+ test('unrecognized extensions get a 404', async () => {
+ const res = await get('/assets/images/_fixtures/screenshot.xxx')
+ expect(res.statusCode).toBe(404)
+ expect(res.headers['content-type']).toMatch(/text\/plain/)
+ })
+
+ test('unrecognized as source PNG get a 404', async () => {
+ const res = await get('/assets/images/_fixtures/sceeeeenshoot.png')
+ expect(res.statusCode).toBe(404)
+ expect(res.headers['content-type']).toMatch(/text\/plain/)
+ })
+
+ test('recognized extensions but no equivalent .png get a 404', async () => {
+ const res = await get('/assets/images/_fixtures/neverheardof.webp')
+ expect(res.statusCode).toBe(404)
+ expect(res.headers['content-type']).toMatch(/text\/plain/)
+ })
+
+ test.each(['key', 'key=value'])('any query string (%p) triggers a redirect', async (qs) => {
+ const res = await get('/assets/images/_fixtures/screenshot.webp?' + qs)
+ expect(res.statusCode).toBe(302)
+ expect(res.headers.location).toBe('/assets/images/_fixtures/screenshot.webp')
+ expect(res.headers['cache-control']).toContain('public')
+ expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/)
+ })
+})
diff --git a/tests/rendering-fixtures/images.js b/tests/rendering-fixtures/images.js
new file mode 100644
index 000000000000..98de501a7730
--- /dev/null
+++ b/tests/rendering-fixtures/images.js
@@ -0,0 +1,44 @@
+import sharp from 'sharp'
+
+import { get, getDOM } from '../helpers/e2etest.js'
+
+describe('render Markdown image tags', () => {
+ test('page with a single image', async () => {
+ const $ = await getDOM('/get-started/foo/single-image')
+
+ const pictures = $('#article-contents picture')
+ expect(pictures.length).toBe(1)
+
+ const sources = $('source', pictures)
+ // Note: We might support AVIF too at some point, then
+ // this test needs to change.
+ expect(sources.length).toBe(1)
+ const srcset = sources.attr('srcset')
+ expect(srcset).toBe('/assets/cb-914945/mw-1000/images/_fixtures/screenshot.webp')
+ const type = sources.attr('type')
+ expect(type).toBe('image/webp')
+
+ const imgs = $('img', pictures)
+ expect(imgs.length).toBe(1)
+ const src = imgs.attr('src')
+ expect(src).toBe('/assets/cb-914945/images/_fixtures/screenshot.png')
+ const alt = imgs.attr('alt')
+ expect(alt).toBe('This is the alt text')
+
+ const res = await get(srcset, { responseType: 'buffer' })
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['content-type']).toBe('image/webp')
+
+ // The fixture image `_fixtures/screenshot.png` is known to be very
+ // large. Larger than 1,000 pixels wide.
+ // When transformed as a source in a `` tag, it's automatically
+ // injected with the `mw-XXXXX` virtual indicator in the URL that
+ // resizes it on-the-fly.
+ const image = sharp(res.text)
+ const { width, height } = await image.metadata()
+ expect(width).toBe(1000)
+ // The `_fixtures/screenshot.png` is 2000x1494.
+ // So if 2000/1494==1000/x, then x becomes 1494*1000/2000=747
+ expect(height).toBe(747)
+ })
+})
diff --git a/tests/rendering/dynamic-assets.js b/tests/rendering/dynamic-assets.js
deleted file mode 100644
index 4cdfe12475f9..000000000000
--- a/tests/rendering/dynamic-assets.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { jest } from '@jest/globals'
-
-import { SURROGATE_ENUMS } from '../../middleware/set-fastly-surrogate-key.js'
-import { get, head } from '../helpers/e2etest.js'
-
-const POSSIBLE_EXTENSIONS = ['webp', 'avif']
-
-const EXPECTED_MIMETYPES = {
- webp: 'image/webp',
- avif: 'image/avif',
-}
-
-describe('dynamic assets', () => {
- jest.setTimeout(3 * 60 * 1000)
-
- test.each(POSSIBLE_EXTENSIONS)('GET logo PNG as a %s', async (extension) => {
- const res = await get(`/assets/images/site/logo.${extension}`)
- expect(res.statusCode).toBe(200)
- expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension])
- })
-
- test.each(POSSIBLE_EXTENSIONS)('HEAD logo PNG as a %s', async (extension) => {
- const res = await head(`/assets/images/site/logo.${extension}`)
- expect(res.statusCode).toBe(200)
- expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension])
- })
-
- test.each(POSSIBLE_EXTENSIONS)('get PNG as a %s with cache busting prefix', async (extension) => {
- const res = await get(`/assets/cb-12345/images/site/logo.${extension}`)
- expect(res.statusCode).toBe(200)
- expect(res.headers['cache-control']).toContain('public')
- expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/)
- expect(res.headers['surrogate-key']).toBe(SURROGATE_ENUMS.MANUAL)
- })
-
- test('unrecognized extensions get a 404', async () => {
- const res = await get('/assets/images/site/logo.xxx')
- expect(res.statusCode).toBe(404)
- expect(res.headers['content-type']).toMatch(/text\/plain/)
- })
-
- test('unrecognized as source PNG get a 404', async () => {
- const res = await get('/assets/images/site/loooogo.png')
- expect(res.statusCode).toBe(404)
- expect(res.headers['content-type']).toMatch(/text\/plain/)
- })
-
- test('recognized extensions but no equivalent .png get a 404', async () => {
- const res = await get('/assets/images/site/neverheardof.webp')
- expect(res.statusCode).toBe(404)
- expect(res.headers['content-type']).toMatch(/text\/plain/)
- })
-
- test.each(['key', 'key=value'])('any query string (%p) triggers a redirect', async (qs) => {
- const res = await get('/assets/images/site/logo.webp?' + qs)
- expect(res.statusCode).toBe(302)
- expect(res.headers.location).toBe('/assets/images/site/logo.webp')
- expect(res.headers['cache-control']).toContain('public')
- expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/)
- })
-})