Skip to content

Commit 1fd4745

Browse files
chore(repo): cover subdir and dependency watcher rebuilds (#459)
Co-authored-by: CharlieHelps <charlie@charlielabs.ai>
1 parent 840aecf commit 1fd4745

1 file changed

Lines changed: 126 additions & 48 deletions

File tree

Lines changed: 126 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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';
33
import os from 'node:os';
44
import { join } from 'node:path';
55

@@ -10,7 +10,7 @@ import { getHTML } from './helpers/html.js';
1010
const timeout = { timeout: 15e3 };
1111
const tempRoot = process.env.TMPDIR || os.tmpdir();
1212
const 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');
1414
const templates = [
1515
{ buttonName: 'Base', snapshotName: 'Base' },
1616
{ buttonName: 'Code', snapshotName: 'Code' },
@@ -23,6 +23,44 @@ const templates = [
2323
];
2424
let 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+
2664
const 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 = () => {
3775
const 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

57114
test.describe.configure({ mode: 'serial' });
@@ -86,43 +143,64 @@ test('templates', async ({ page }) => {
86143
});
87144

88145
test('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', /Text Content/, { 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', /Removed Content/, { 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

Comments
 (0)