diff --git a/test/smoke-v2/tests/smoke-v2.test.ts b/test/smoke-v2/tests/smoke-v2.test.ts index d537a535..c22359a6 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'; @@ -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,21 +75,40 @@ const getIndexUrl = () => { const getTemplateButton = (page: Page, name: string) => page.locator('#templates-window').getByRole('button', { name, exact: true }); -const reloadPreview = async (page: Page) => { - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await page.goto(getIndexUrl()); - return; - } catch (error) { - if (!String(error).includes('net::ERR_ABORTED')) { - throw error; +const getTemplateUrl = (templateSlug: string) => + `${getIndexUrl()}#/${encodeURIComponent(templateSlug)}`; + +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); +}; - await page.waitForTimeout(500); - } - } +const replaceFileContents = async (filePath: string, content: string) => { + const handle = await open(filePath, 'r+'); - await page.goto(getIndexUrl()); + try { + await handle.writeFile(content, 'utf8'); + await handle.truncate(Buffer.byteLength(content, 'utf8')); + } finally { + await handle.close(); + } }; test.describe.configure({ mode: 'serial' }); @@ -86,43 +143,64 @@ 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 replaceFileContents(targetFilePath, contents.replace(beforeContent, afterContent)); + await waitForPreviewBuild(previewBuildFilePath, afterContent); + + // 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', + 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 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); + } + }); } });