11/* eslint-disable no-await-in-loop, no-underscore-dangle */
2- import { readFile , writeFile } from 'node:fs/promises' ;
2+ import { open , readFile } from 'node:fs/promises' ;
33import os from 'node:os' ;
44import { join } from 'node:path' ;
55
@@ -10,7 +10,7 @@ import { getHTML } from './helpers/html.js';
1010const timeout = { timeout : 15e3 } ;
1111const tempRoot = process . env . TMPDIR || os . tmpdir ( ) ;
1212const defaultStatePath = join ( tempRoot , 'jsx-email-smoke-v2.state' ) ;
13- const defaultPreviewBuildFilePath = join ( tempRoot , 'jsx-email' , 'preview' , 'base.js ') ;
13+ const defaultPreviewBuildDirPath = join ( tempRoot , 'jsx-email' , 'preview' ) ;
1414const templates = [
1515 { buttonName : 'Base' , snapshotName : 'Base' } ,
1616 { buttonName : 'Code' , snapshotName : 'Code' } ,
@@ -23,6 +23,44 @@ const templates = [
2323] ;
2424let navigationSequence = 0 ;
2525
26+ type WatcherCase = {
27+ afterContent : string ;
28+ beforeContent : string ;
29+ previewBuildFileName : string ;
30+ snapshotName ?: string ;
31+ stepName : string ;
32+ templateSlug : string ;
33+ targetRelativePath : string ;
34+ } ;
35+
36+ const watcherCases : WatcherCase [ ] = [
37+ {
38+ afterContent : 'Removed Content' ,
39+ beforeContent : 'Text Content' ,
40+ previewBuildFileName : 'base' ,
41+ snapshotName : 'watcher.snap' ,
42+ stepName : 'template edit: Base template source file' ,
43+ templateSlug : 'base' ,
44+ targetRelativePath : 'fixtures/templates/base.tsx'
45+ } ,
46+ {
47+ afterContent : 'robin' ,
48+ beforeContent : 'batman' ,
49+ previewBuildFileName : 'preview-props' ,
50+ stepName : 'template edit: subdirectory template source file' ,
51+ templateSlug : 'props-preview-props' ,
52+ targetRelativePath : 'fixtures/templates/props/preview-props.tsx'
53+ } ,
54+ {
55+ afterContent : 'component test updated' ,
56+ beforeContent : 'component test' ,
57+ previewBuildFileName : 'base' ,
58+ stepName : 'template rebuild: imported dependency file change' ,
59+ templateSlug : 'base' ,
60+ targetRelativePath : 'fixtures/components/text.tsx'
61+ }
62+ ] ;
63+
2664const getSmokeProjectDir = async ( ) => {
2765 const statePath = process . env . SMOKE_V2_STATE_PATH || defaultStatePath ;
2866 return ( await readFile ( statePath , 'utf8' ) ) . trim ( ) ;
@@ -37,21 +75,40 @@ const getIndexUrl = () => {
3775const getTemplateButton = ( page : Page , name : string ) =>
3876 page . locator ( '#templates-window' ) . getByRole ( 'button' , { name, exact : true } ) ;
3977
40- const reloadPreview = async ( page : Page ) => {
41- for ( let attempt = 0 ; attempt < 3 ; attempt += 1 ) {
42- try {
43- await page . goto ( getIndexUrl ( ) ) ;
44- return ;
45- } catch ( error ) {
46- if ( ! String ( error ) . includes ( 'net::ERR_ABORTED' ) ) {
47- throw error ;
78+ const getTemplateUrl = ( templateSlug : string ) =>
79+ `${ getIndexUrl ( ) } #/${ encodeURIComponent ( templateSlug ) } ` ;
80+
81+ const escapeForRegExp = ( value : string ) => value . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) ;
82+
83+ const getPreviewBuildFilePath = ( templateName : string ) =>
84+ join ( defaultPreviewBuildDirPath , `${ templateName } .js` ) ;
85+
86+ const waitForPreviewBuild = async ( previewBuildFilePath : string , expectedContent : string ) => {
87+ await expect
88+ . poll (
89+ async ( ) => {
90+ try {
91+ return ( await readFile ( previewBuildFilePath , 'utf8' ) ) . includes ( expectedContent ) ;
92+ } catch {
93+ return false ;
94+ }
95+ } ,
96+ {
97+ timeout : 60e3
4898 }
99+ )
100+ . toBe ( true ) ;
101+ } ;
49102
50- await page . waitForTimeout ( 500 ) ;
51- }
52- }
103+ const replaceFileContents = async ( filePath : string , content : string ) => {
104+ const handle = await open ( filePath , 'r+' ) ;
53105
54- await page . goto ( getIndexUrl ( ) ) ;
106+ try {
107+ await handle . writeFile ( content , 'utf8' ) ;
108+ await handle . truncate ( Buffer . byteLength ( content , 'utf8' ) ) ;
109+ } finally {
110+ await handle . close ( ) ;
111+ }
55112} ;
56113
57114test . describe . configure ( { mode : 'serial' } ) ;
@@ -86,43 +143,64 @@ test('templates', async ({ page }) => {
86143} ) ;
87144
88145test ( 'watcher' , async ( { page } ) => {
89- test . setTimeout ( 90e3 ) ;
146+ test . setTimeout ( 3 * 60e3 ) ;
90147
91148 const smokeProjectDir = await getSmokeProjectDir ( ) ;
92- const targetFilePath = join ( smokeProjectDir , 'fixtures/templates/base.tsx' ) ;
93- const contents = await readFile ( targetFilePath , 'utf8' ) ;
94-
95- try {
96- await page . goto ( getIndexUrl ( ) ) ;
97- await getTemplateButton ( page , 'Base' ) . click ( timeout ) ;
98-
99- const iframeEl = page . locator ( 'iframe' ) ;
100- await expect ( iframeEl ) . toHaveCount ( 1 , { timeout : 30e3 } ) ;
101- await expect ( iframeEl ) . toHaveAttribute ( 'srcdoc' , / T e x t C o n t e n t / , { timeout : 30e3 } ) ;
102-
103- await writeFile ( targetFilePath , contents . replace ( 'Text Content' , 'Removed Content' ) , 'utf8' ) ;
104149
105- await expect
106- . poll (
107- async ( ) =>
108- ( await readFile ( defaultPreviewBuildFilePath , 'utf8' ) ) . includes ( 'Removed Content' ) ,
109- {
110- timeout : 60e3
150+ for ( const watcherCase of watcherCases ) {
151+ await test . step ( watcherCase . stepName , async ( ) => {
152+ const {
153+ afterContent,
154+ beforeContent,
155+ previewBuildFileName,
156+ snapshotName,
157+ templateSlug,
158+ targetRelativePath
159+ } = watcherCase ;
160+ const targetFilePath = join ( smokeProjectDir , targetRelativePath ) ;
161+ const previewBuildFilePath = getPreviewBuildFilePath ( previewBuildFileName ) ;
162+ const contents = await readFile ( targetFilePath , 'utf8' ) ;
163+
164+ expect ( contents ) . toContain ( beforeContent ) ;
165+
166+ try {
167+ await page . goto ( getTemplateUrl ( templateSlug ) ) ;
168+
169+ const iframeEl = page . locator ( 'iframe' ) ;
170+ await expect ( iframeEl ) . toHaveCount ( 1 , { timeout : 30e3 } ) ;
171+ await expect ( iframeEl ) . toHaveAttribute (
172+ 'srcdoc' ,
173+ new RegExp ( escapeForRegExp ( beforeContent ) ) ,
174+ {
175+ timeout : 30e3
176+ }
177+ ) ;
178+
179+ await replaceFileContents ( targetFilePath , contents . replace ( beforeContent , afterContent ) ) ;
180+ await waitForPreviewBuild ( previewBuildFilePath , afterContent ) ;
181+
182+ // Assert the preview iframe updates in-place after the watcher rebuild, without a forced reload.
183+ await expect ( iframeEl ) . toHaveCount ( 1 , { timeout : 30e3 } ) ;
184+ await expect ( iframeEl ) . toHaveAttribute (
185+ 'srcdoc' ,
186+ new RegExp ( escapeForRegExp ( afterContent ) ) ,
187+ {
188+ timeout : 60e3
189+ }
190+ ) ;
191+
192+ if ( snapshotName ) {
193+ const srcdoc = await iframeEl . getAttribute ( 'srcdoc' ) ;
194+ const html = await getHTML ( srcdoc || '' ) ;
195+
196+ expect ( html ) . toMatchSnapshot ( { name : snapshotName } ) ;
111197 }
112- )
113- . toBe ( true ) ;
114-
115- // When templates rebuild, Vite's HMR doesn't always update the iframe content deterministically.
116- // Navigating to a fresh URL ensures the latest compiled template HTML is reflected in `srcdoc`.
117- await reloadPreview ( page ) ;
118- await getTemplateButton ( page , 'Base' ) . click ( timeout ) ;
119- await expect ( iframeEl ) . toHaveCount ( 1 , { timeout : 30e3 } ) ;
120- await expect ( iframeEl ) . toHaveAttribute ( 'srcdoc' , / R e m o v e d C o n t e n t / , { timeout : 60e3 } ) ;
121-
122- const srcdoc = await iframeEl . getAttribute ( 'srcdoc' ) ;
123- const html = await getHTML ( srcdoc || '' ) ;
124- expect ( html ) . toMatchSnapshot ( { name : 'watcher.snap' } ) ;
125- } finally {
126- await writeFile ( targetFilePath , contents , 'utf8' ) ;
198+ } finally {
199+ await replaceFileContents ( targetFilePath , contents ) ;
200+ // Ensure restore-triggered rebuilds settle before the next watcher step mutates a file.
201+ // On Windows, rapid back-to-back edits can overlap in the watcher and destabilize preview.
202+ await waitForPreviewBuild ( previewBuildFilePath , beforeContent ) ;
203+ }
204+ } ) ;
127205 }
128206} ) ;
0 commit comments