From 52fbdee8c81c66680bbb0136e53007ac80b8957f Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Thu, 4 Jun 2026 17:29:25 +0000 Subject: [PATCH 1/4] test(smoke-v2): cover subdir and dependency watcher rebuilds --- test/smoke-v2/tests/smoke-v2.test.ts | 161 ++++++++++++++++++++------- 1 file changed, 123 insertions(+), 38 deletions(-) diff --git a/test/smoke-v2/tests/smoke-v2.test.ts b/test/smoke-v2/tests/smoke-v2.test.ts index d537a535..63066dfe 100644 --- a/test/smoke-v2/tests/smoke-v2.test.ts +++ b/test/smoke-v2/tests/smoke-v2.test.ts @@ -10,7 +10,7 @@ import { getHTML } from './helpers/html.js'; const timeout = { timeout: 15e3 }; const tempRoot = process.env.TMPDIR || os.tmpdir(); const defaultStatePath = join(tempRoot, 'jsx-email-smoke-v2.state'); -const defaultPreviewBuildFilePath = join(tempRoot, 'jsx-email', 'preview', 'base.js'); +const defaultPreviewBuildDirPath = join(tempRoot, 'jsx-email', 'preview'); const templates = [ { buttonName: 'Base', snapshotName: 'Base' }, { buttonName: 'Code', snapshotName: 'Code' }, @@ -23,6 +23,44 @@ const templates = [ ]; let navigationSequence = 0; +type WatcherCase = { + afterContent: string; + beforeContent: string; + previewBuildFileName: string; + snapshotName?: string; + stepName: string; + templateSlug: string; + targetRelativePath: string; +}; + +const watcherCases: WatcherCase[] = [ + { + afterContent: 'Removed Content', + beforeContent: 'Text Content', + previewBuildFileName: 'base', + snapshotName: 'watcher.snap', + stepName: 'template edit: Base template source file', + templateSlug: 'base', + targetRelativePath: 'fixtures/templates/base.tsx' + }, + { + afterContent: 'robin', + beforeContent: 'batman', + previewBuildFileName: 'preview-props', + stepName: 'template edit: subdirectory template source file', + templateSlug: 'props-preview-props', + targetRelativePath: 'fixtures/templates/props/preview-props.tsx' + }, + { + afterContent: 'component test updated', + beforeContent: 'component test', + previewBuildFileName: 'base', + stepName: 'template rebuild: imported dependency file change', + templateSlug: 'base', + targetRelativePath: 'fixtures/components/text.tsx' + } +]; + const getSmokeProjectDir = async () => { const statePath = process.env.SMOKE_V2_STATE_PATH || defaultStatePath; return (await readFile(statePath, 'utf8')).trim(); @@ -37,10 +75,15 @@ const getIndexUrl = () => { const getTemplateButton = (page: Page, name: string) => page.locator('#templates-window').getByRole('button', { name, exact: true }); -const reloadPreview = async (page: Page) => { +const getTemplateUrl = (templateSlug: string) => + `${getIndexUrl()}#/${encodeURIComponent(templateSlug)}`; + +const reloadPreview = async (page: Page, templateSlug: string) => { + const templateUrl = getTemplateUrl(templateSlug); + for (let attempt = 0; attempt < 3; attempt += 1) { try { - await page.goto(getIndexUrl()); + await page.goto(templateUrl); return; } catch (error) { if (!String(error).includes('net::ERR_ABORTED')) { @@ -51,7 +94,29 @@ const reloadPreview = async (page: Page) => { } } - await page.goto(getIndexUrl()); + await page.goto(templateUrl); +}; + +const escapeForRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const getPreviewBuildFilePath = (templateName: string) => + join(defaultPreviewBuildDirPath, `${templateName}.js`); + +const waitForPreviewBuild = async (previewBuildFilePath: string, expectedContent: string) => { + await expect + .poll( + async () => { + try { + return (await readFile(previewBuildFilePath, 'utf8')).includes(expectedContent); + } catch { + return false; + } + }, + { + timeout: 60e3 + } + ) + .toBe(true); }; test.describe.configure({ mode: 'serial' }); @@ -86,43 +151,63 @@ test('templates', async ({ page }) => { }); test('watcher', async ({ page }) => { - test.setTimeout(90e3); + test.setTimeout(3 * 60e3); const smokeProjectDir = await getSmokeProjectDir(); - const targetFilePath = join(smokeProjectDir, 'fixtures/templates/base.tsx'); - const contents = await readFile(targetFilePath, 'utf8'); - try { - await page.goto(getIndexUrl()); - await getTemplateButton(page, 'Base').click(timeout); - - const iframeEl = page.locator('iframe'); - await expect(iframeEl).toHaveCount(1, { timeout: 30e3 }); - await expect(iframeEl).toHaveAttribute('srcdoc', /Text Content/, { timeout: 30e3 }); - - await writeFile(targetFilePath, contents.replace('Text Content', 'Removed Content'), 'utf8'); - - await expect - .poll( - async () => - (await readFile(defaultPreviewBuildFilePath, 'utf8')).includes('Removed Content'), - { - timeout: 60e3 + for (const watcherCase of watcherCases) { + await test.step(watcherCase.stepName, async () => { + const { + afterContent, + beforeContent, + previewBuildFileName, + snapshotName, + templateSlug, + targetRelativePath + } = watcherCase; + const targetFilePath = join(smokeProjectDir, targetRelativePath); + const previewBuildFilePath = getPreviewBuildFilePath(previewBuildFileName); + const contents = await readFile(targetFilePath, 'utf8'); + + expect(contents).toContain(beforeContent); + + try { + await page.goto(getTemplateUrl(templateSlug)); + + const iframeEl = page.locator('iframe'); + await expect(iframeEl).toHaveCount(1, { timeout: 30e3 }); + await expect(iframeEl).toHaveAttribute( + 'srcdoc', + new RegExp(escapeForRegExp(beforeContent)), + { + timeout: 30e3 + } + ); + + await writeFile(targetFilePath, contents.replace(beforeContent, afterContent), 'utf8'); + await waitForPreviewBuild(previewBuildFilePath, afterContent); + + // When templates rebuild, Vite's HMR doesn't always update the iframe content deterministically. + // Navigating to a fresh URL ensures the latest compiled template HTML is reflected in `srcdoc`. + await reloadPreview(page, templateSlug); + await expect(iframeEl).toHaveCount(1, { timeout: 30e3 }); + await expect(iframeEl).toHaveAttribute( + 'srcdoc', + new RegExp(escapeForRegExp(afterContent)), + { + timeout: 60e3 + } + ); + + if (snapshotName) { + const srcdoc = await iframeEl.getAttribute('srcdoc'); + const html = await getHTML(srcdoc || ''); + + expect(html).toMatchSnapshot({ name: snapshotName }); } - ) - .toBe(true); - - // When templates rebuild, Vite's HMR doesn't always update the iframe content deterministically. - // Navigating to a fresh URL ensures the latest compiled template HTML is reflected in `srcdoc`. - await reloadPreview(page); - await getTemplateButton(page, 'Base').click(timeout); - await expect(iframeEl).toHaveCount(1, { timeout: 30e3 }); - await expect(iframeEl).toHaveAttribute('srcdoc', /Removed Content/, { timeout: 60e3 }); - - const srcdoc = await iframeEl.getAttribute('srcdoc'); - const html = await getHTML(srcdoc || ''); - expect(html).toMatchSnapshot({ name: 'watcher.snap' }); - } finally { - await writeFile(targetFilePath, contents, 'utf8'); + } finally { + await writeFile(targetFilePath, contents, 'utf8'); + } + }); } }); From 6dc8fa8b7fac514d6957249ffc2c87d453ba13e0 Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Thu, 4 Jun 2026 17:49:34 +0000 Subject: [PATCH 2/4] test(smoke-v2): wait for restore rebuilds between watcher cases --- test/smoke-v2/tests/smoke-v2.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/smoke-v2/tests/smoke-v2.test.ts b/test/smoke-v2/tests/smoke-v2.test.ts index 63066dfe..4bfe7846 100644 --- a/test/smoke-v2/tests/smoke-v2.test.ts +++ b/test/smoke-v2/tests/smoke-v2.test.ts @@ -207,6 +207,9 @@ test('watcher', async ({ page }) => { } } finally { await writeFile(targetFilePath, contents, 'utf8'); + // Ensure restore-triggered rebuilds settle before the next watcher step mutates a file. + // On Windows, rapid back-to-back edits can overlap in the watcher and destabilize preview. + await waitForPreviewBuild(previewBuildFilePath, beforeContent); } }); } From ca463c78ff61ffc55e4e80b02c2d34d79038924e Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Thu, 4 Jun 2026 18:04:09 +0000 Subject: [PATCH 3/4] fix(smoke-v2): write watcher fixture edits in place --- test/smoke-v2/tests/smoke-v2.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/smoke-v2/tests/smoke-v2.test.ts b/test/smoke-v2/tests/smoke-v2.test.ts index 4bfe7846..64b3b1d6 100644 --- a/test/smoke-v2/tests/smoke-v2.test.ts +++ b/test/smoke-v2/tests/smoke-v2.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-await-in-loop, no-underscore-dangle */ -import { readFile, writeFile } from 'node:fs/promises'; +import { open, readFile } from 'node:fs/promises'; import os from 'node:os'; import { join } from 'node:path'; @@ -119,6 +119,17 @@ const waitForPreviewBuild = async (previewBuildFilePath: string, expectedContent .toBe(true); }; +const replaceFileContents = async (filePath: string, content: string) => { + const handle = await open(filePath, 'r+'); + + try { + await handle.writeFile(content, 'utf8'); + await handle.truncate(Buffer.byteLength(content, 'utf8')); + } finally { + await handle.close(); + } +}; + test.describe.configure({ mode: 'serial' }); test('landing', async ({ page }) => { @@ -184,7 +195,7 @@ test('watcher', async ({ page }) => { } ); - await writeFile(targetFilePath, contents.replace(beforeContent, afterContent), 'utf8'); + await replaceFileContents(targetFilePath, contents.replace(beforeContent, afterContent)); await waitForPreviewBuild(previewBuildFilePath, afterContent); // When templates rebuild, Vite's HMR doesn't always update the iframe content deterministically. @@ -206,7 +217,7 @@ test('watcher', async ({ page }) => { expect(html).toMatchSnapshot({ name: snapshotName }); } } finally { - await writeFile(targetFilePath, contents, 'utf8'); + await replaceFileContents(targetFilePath, contents); // Ensure restore-triggered rebuilds settle before the next watcher step mutates a file. // On Windows, rapid back-to-back edits can overlap in the watcher and destabilize preview. await waitForPreviewBuild(previewBuildFilePath, beforeContent); From 039948735fe86b67a7753b8468d7cbcbf4ca5791 Mon Sep 17 00:00:00 2001 From: CharlieHelps Date: Thu, 4 Jun 2026 19:27:45 +0000 Subject: [PATCH 4/4] test(smoke-v2): assert watcher updates without forced reload --- test/smoke-v2/tests/smoke-v2.test.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/test/smoke-v2/tests/smoke-v2.test.ts b/test/smoke-v2/tests/smoke-v2.test.ts index 64b3b1d6..c22359a6 100644 --- a/test/smoke-v2/tests/smoke-v2.test.ts +++ b/test/smoke-v2/tests/smoke-v2.test.ts @@ -78,25 +78,6 @@ const getTemplateButton = (page: Page, name: string) => const getTemplateUrl = (templateSlug: string) => `${getIndexUrl()}#/${encodeURIComponent(templateSlug)}`; -const reloadPreview = async (page: Page, templateSlug: string) => { - const templateUrl = getTemplateUrl(templateSlug); - - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await page.goto(templateUrl); - return; - } catch (error) { - if (!String(error).includes('net::ERR_ABORTED')) { - throw error; - } - - await page.waitForTimeout(500); - } - } - - await page.goto(templateUrl); -}; - const escapeForRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const getPreviewBuildFilePath = (templateName: string) => @@ -198,9 +179,7 @@ test('watcher', async ({ page }) => { await replaceFileContents(targetFilePath, contents.replace(beforeContent, afterContent)); await waitForPreviewBuild(previewBuildFilePath, afterContent); - // When templates rebuild, Vite's HMR doesn't always update the iframe content deterministically. - // Navigating to a fresh URL ensures the latest compiled template HTML is reflected in `srcdoc`. - await reloadPreview(page, templateSlug); + // Assert the preview iframe updates in-place after the watcher rebuild, without a forced reload. await expect(iframeEl).toHaveCount(1, { timeout: 30e3 }); await expect(iframeEl).toHaveAttribute( 'srcdoc',