@@ -4,18 +4,21 @@ import os from 'node:os';
44import path from 'node:path' ;
55import type { RouteManifest } from 'pyrajs-shared' ;
66
7- // Mock pyrajs-shared so `log` is available after vi.resetModules() calls.
8- // vi.mock() is hoisted and survives module resets , so image-plugin.ts always
9- // gets this mock when it's re-imported in each test.
10- vi . mock ( 'pyrajs-shared' , ( ) => ( {
11- log : {
12- info : vi . fn ( ) ,
13- success : vi . fn ( ) ,
14- warn : vi . fn ( ) ,
15- error : vi . fn ( ) ,
16- } ,
7+ // ─── Mocks ────────────────────────────────────────────────────────────────────
8+ // vi.mock() is hoisted above all imports by vitest , so image-plugin.ts gets the
9+ // mocked optimizer when it is statically imported below. This removes the need
10+ // for vi.resetModules() + dynamic imports, which bypassed the Vite alias and
11+ // caused "Failed to resolve entry for pyrajs-shared" in CI environments where
12+ // packages/shared/dist/ has not been built yet.
13+ vi . mock ( '../image-optimizer.js' , ( ) => ( {
14+ isSharpAvailable : vi . fn ( ) ,
15+ getImageMetadata : vi . fn ( ) ,
16+ optimizeImage : vi . fn ( ) ,
1717} ) ) ;
1818
19+ import { pyraImages } from '../plugins/image-plugin.js' ;
20+ import * as imageOptimizer from '../image-optimizer.js' ;
21+
1922// ─── Filesystem helpers ───────────────────────────────────────────────────────
2023
2124let tmpDir : string ;
@@ -27,30 +30,25 @@ function writeFakeImage(relPath: string, content = 'fake-image-bytes'): string {
2730 return abs ;
2831}
2932
30- // ─── Mocks ────────────────────────────────────────────────────────────────────
33+ // ─── Mock configuration helpers ───────────────────────────────────────────────
34+
35+ function mockSharpAvailable (
36+ buffer = Buffer . from ( 'optimized-image-data' ) ,
37+ metadata = { width : 1000 , height : 750 , format : 'jpeg' } ,
38+ ) {
39+ vi . mocked ( imageOptimizer . isSharpAvailable ) . mockResolvedValue ( true ) ;
40+ vi . mocked ( imageOptimizer . getImageMetadata ) . mockResolvedValue ( metadata ) ;
41+ vi . mocked ( imageOptimizer . optimizeImage ) . mockResolvedValue ( {
42+ buffer,
43+ width : metadata . width ,
44+ height : metadata . height ,
45+ format : 'webp' ,
46+ size : buffer . length ,
47+ } ) ;
48+ }
3149
32- /** Resets module registry so fresh imports get fresh module-level state. */
33- function resetAndMockOptimizer ( overrides : {
34- available ?: boolean ;
35- metadata ?: { width : number ; height : number ; format : string } ;
36- buffer ?: Buffer ;
37- } = { } ) {
38- vi . resetModules ( ) ;
39- const available = overrides . available ?? true ;
40- const metadata = overrides . metadata ?? { width : 1000 , height : 750 , format : 'jpeg' } ;
41- const buffer = overrides . buffer ?? Buffer . from ( 'optimized-image-data' ) ;
42-
43- vi . doMock ( '../image-optimizer.js' , ( ) => ( {
44- isSharpAvailable : vi . fn ( ) . mockResolvedValue ( available ) ,
45- getImageMetadata : vi . fn ( ) . mockResolvedValue ( metadata ) ,
46- optimizeImage : vi . fn ( ) . mockResolvedValue ( {
47- buffer,
48- width : metadata . width ,
49- height : metadata . height ,
50- format : 'webp' ,
51- size : buffer . length ,
52- } ) ,
53- } ) ) ;
50+ function mockSharpUnavailable ( ) {
51+ vi . mocked ( imageOptimizer . isSharpAvailable ) . mockResolvedValue ( false ) ;
5452}
5553
5654// ─── Setup / teardown ─────────────────────────────────────────────────────────
@@ -61,47 +59,39 @@ beforeEach(() => {
6159
6260afterEach ( ( ) => {
6361 fs . rmSync ( tmpDir , { recursive : true , force : true } ) ;
64- vi . doUnmock ( '../image-optimizer.js' ) ;
62+ vi . clearAllMocks ( ) ;
6563} ) ;
6664
67- // ─── Plugin identity ─────────────────────────────────────────────────────────
65+ // ─── Plugin identity ──────────────────────────────────────────────────────────
6866
6967describe ( 'pyraImages() — plugin identity' , ( ) => {
70- beforeEach ( ( ) => resetAndMockOptimizer ( ) ) ;
68+ beforeEach ( ( ) => mockSharpAvailable ( ) ) ;
7169
72- it ( 'has name "pyra:images"' , async ( ) => {
73- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
70+ it ( 'has name "pyra:images"' , ( ) => {
7471 const plugin = pyraImages ( ) ;
7572 expect ( plugin . name ) . toBe ( 'pyra:images' ) ;
7673 } ) ;
7774
78- it ( 'works with no arguments (all defaults)' , async ( ) => {
79- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
75+ it ( 'works with no arguments (all defaults)' , ( ) => {
8076 expect ( ( ) => pyraImages ( ) ) . not . toThrow ( ) ;
8177 } ) ;
8278
83- it ( 'works with partial config' , async ( ) => {
84- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
79+ it ( 'works with partial config' , ( ) => {
8580 expect ( ( ) => pyraImages ( { formats : [ 'avif' ] , quality : 90 } ) ) . not . toThrow ( ) ;
8681 } ) ;
8782} ) ;
8883
8984// ─── Plugin hooks — buildEnd ──────────────────────────────────────────────────
9085
9186describe ( 'pyraImages() — buildEnd()' , ( ) => {
92- beforeEach ( ( ) => resetAndMockOptimizer ( ) ) ;
87+ beforeEach ( ( ) => mockSharpAvailable ( ) ) ;
9388
9489 it ( 'sets manifest.images when variants were built' , async ( ) => {
95- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
9690 const plugin = pyraImages ( ) ;
97-
98- // Seed internal builtImages by simulating a successful buildStart
99- const publicDir = path . join ( tmpDir , 'public' ) ;
10091 const outDir = path . join ( tmpDir , 'dist' ) ;
10192 writeFakeImage ( 'public/hero.jpg' ) ;
10293 fs . mkdirSync ( path . join ( outDir , 'client' , '_images' ) , { recursive : true } ) ;
10394
104- // Simulate setup and buildStart lifecycle
10595 await plugin . setup ?.( {
10696 addEsbuildPlugin : vi . fn ( ) ,
10797 getConfig : vi . fn ( ) . mockReturnValue ( {
@@ -120,10 +110,7 @@ describe('pyraImages() — buildEnd()', () => {
120110 } ) ;
121111
122112 it ( 'does not set manifest.images when no images were found' , async ( ) => {
123- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
124113 const plugin = pyraImages ( ) ;
125-
126- // Empty public dir — no images
127114 fs . mkdirSync ( path . join ( tmpDir , 'public' ) , { recursive : true } ) ;
128115 fs . mkdirSync ( path . join ( tmpDir , 'dist' , 'client' , '_images' ) , { recursive : true } ) ;
129116
@@ -143,8 +130,7 @@ describe('pyraImages() — buildEnd()', () => {
143130 expect ( manifest . images ) . toBeUndefined ( ) ;
144131 } ) ;
145132
146- it ( 'buildEnd does not throw when called before buildStart' , async ( ) => {
147- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
133+ it ( 'buildEnd does not throw when called before buildStart' , ( ) => {
148134 const plugin = pyraImages ( ) ;
149135 const manifest : RouteManifest = { routes : { } } ;
150136 expect ( ( ) =>
@@ -156,14 +142,14 @@ describe('pyraImages() — buildEnd()', () => {
156142// ─── Plugin hooks — buildStart (sharp available) ──────────────────────────────
157143
158144describe ( 'pyraImages() — buildStart() with sharp available' , ( ) => {
159- beforeEach ( ( ) => resetAndMockOptimizer ( {
160- available : true ,
161- metadata : { width : 1000 , height : 750 , format : 'jpeg' } ,
162- buffer : Buffer . from ( 'x' . repeat ( 1234 ) ) ,
163- } ) ) ;
145+ beforeEach ( ( ) =>
146+ mockSharpAvailable (
147+ Buffer . from ( 'x' . repeat ( 1234 ) ) ,
148+ { width : 1000 , height : 750 , format : 'jpeg' } ,
149+ )
150+ ) ;
164151
165152 async function setupPlugin ( config : Record < string , unknown > = { } ) {
166- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
167153 const plugin = pyraImages ( { formats : [ 'webp' ] , sizes : [ 640 , 1280 ] , quality : 80 } ) ;
168154 fs . mkdirSync ( path . join ( tmpDir , 'public' ) , { recursive : true } ) ;
169155 fs . mkdirSync ( path . join ( tmpDir , 'dist' , 'client' , '_images' ) , { recursive : true } ) ;
@@ -181,16 +167,13 @@ describe('pyraImages() — buildStart() with sharp available', () => {
181167
182168 it ( 'skips when public dir has no images' , async ( ) => {
183169 const plugin = await setupPlugin ( ) ;
184- // No files in public/
185170 await expect ( plugin . buildStart ?.( ) ) . resolves . not . toThrow ( ) ;
186171 const manifest : RouteManifest = { routes : { } } ;
187172 plugin . buildEnd ?.( { manifest, outDir : path . join ( tmpDir , 'dist' ) , root : tmpDir } ) ;
188173 expect ( manifest . images ) . toBeUndefined ( ) ;
189174 } ) ;
190175
191176 it ( 'skips when public dir does not exist' , async ( ) => {
192- // Don't create the public dir
193- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
194177 const plugin = pyraImages ( { formats : [ 'webp' ] } ) ;
195178 await plugin . setup ?.( {
196179 addEsbuildPlugin : vi . fn ( ) ,
@@ -247,7 +230,6 @@ describe('pyraImages() — buildStart() with sharp available', () => {
247230 const manifest : RouteManifest = { routes : { } } ;
248231 plugin . buildEnd ?.( { manifest, outDir : path . join ( tmpDir , 'dist' ) , root : tmpDir } ) ;
249232 const entry = manifest . images ?. [ '/img.jpg' ] ;
250- // Should have keys like "640:webp", "1280:webp"
251233 expect ( Object . keys ( entry ?. variants ?? { } ) ) . toEqual (
252234 expect . arrayContaining ( [ '640:webp' ] )
253235 ) ;
@@ -277,9 +259,7 @@ describe('pyraImages() — buildStart() with sharp available', () => {
277259 } ) ;
278260
279261 it ( 'never generates variants wider than the original image' , async ( ) => {
280- // Original is 1000px wide; requesting 1280w should be skipped
281262 writeFakeImage ( 'public/small.jpg' ) ;
282- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
283263 const plugin = pyraImages ( { formats : [ 'webp' ] , sizes : [ 640 , 1280 ] } ) ;
284264 fs . mkdirSync ( path . join ( tmpDir , 'dist' , 'client' , '_images' ) , { recursive : true } ) ;
285265 await plugin . setup ?.( {
@@ -291,7 +271,6 @@ describe('pyraImages() — buildStart() with sharp available', () => {
291271 const manifest : RouteManifest = { routes : { } } ;
292272 plugin . buildEnd ?.( { manifest, outDir : path . join ( tmpDir , 'dist' ) , root : tmpDir } ) ;
293273 const entry = manifest . images ?. [ '/small.jpg' ] ;
294- // 640w should exist (640 < 1000), 1280w should not (1280 > 1000)
295274 expect ( entry ?. variants [ '640:webp' ] ) . toBeDefined ( ) ;
296275 expect ( entry ?. variants [ '1280:webp' ] ) . toBeUndefined ( ) ;
297276 } ) ;
@@ -318,7 +297,6 @@ describe('pyraImages() — buildStart() with sharp available', () => {
318297
319298 it ( 'ignores non-image files in public/' , async ( ) => {
320299 writeFakeImage ( 'public/img.jpg' ) ;
321- // Non-image files that should be ignored
322300 fs . writeFileSync ( path . join ( tmpDir , 'public' , 'styles.css' ) , 'body {}' ) ;
323301 fs . writeFileSync ( path . join ( tmpDir , 'public' , 'robots.txt' ) , 'User-agent: *' ) ;
324302 const plugin = await setupPlugin ( ) ;
@@ -333,11 +311,10 @@ describe('pyraImages() — buildStart() with sharp available', () => {
333311// ─── Plugin hooks — buildStart (sharp unavailable) ───────────────────────────
334312
335313describe ( 'pyraImages() — buildStart() with sharp unavailable' , ( ) => {
336- beforeEach ( ( ) => resetAndMockOptimizer ( { available : false } ) ) ;
314+ beforeEach ( ( ) => mockSharpUnavailable ( ) ) ;
337315
338316 it ( 'returns without throwing when sharp is missing' , async ( ) => {
339317 writeFakeImage ( 'public/hero.jpg' ) ;
340- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
341318 const plugin = pyraImages ( ) ;
342319 fs . mkdirSync ( path . join ( tmpDir , 'dist' , 'client' , '_images' ) , { recursive : true } ) ;
343320 await plugin . setup ?.( {
@@ -350,7 +327,6 @@ describe('pyraImages() — buildStart() with sharp unavailable', () => {
350327
351328 it ( 'does not populate manifest.images when sharp is missing' , async ( ) => {
352329 writeFakeImage ( 'public/hero.jpg' ) ;
353- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
354330 const plugin = pyraImages ( ) ;
355331 fs . mkdirSync ( path . join ( tmpDir , 'dist' , 'client' , '_images' ) , { recursive : true } ) ;
356332 await plugin . setup ?.( {
@@ -368,17 +344,15 @@ describe('pyraImages() — buildStart() with sharp unavailable', () => {
368344// ─── config() hook ────────────────────────────────────────────────────────────
369345
370346describe ( 'pyraImages() — config() hook' , ( ) => {
371- beforeEach ( ( ) => resetAndMockOptimizer ( ) ) ;
347+ beforeEach ( ( ) => mockSharpAvailable ( ) ) ;
372348
373- it ( 'returns null (does not mutate config)' , async ( ) => {
374- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
349+ it ( 'returns null (does not mutate config)' , ( ) => {
375350 const plugin = pyraImages ( ) ;
376351 const result = plugin . config ?.( { root : '/app' } , 'production' ) ;
377352 expect ( result ) . toBeNull ( ) ;
378353 } ) ;
379354
380- it ( 'config hook is defined' , async ( ) => {
381- const { pyraImages } = await import ( '../plugins/image-plugin.js' ) ;
355+ it ( 'config hook is defined' , ( ) => {
382356 const plugin = pyraImages ( ) ;
383357 expect ( typeof plugin . config ) . toBe ( 'function' ) ;
384358 } ) ;
0 commit comments