diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 7861080c..60ce8ae5 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -1,6 +1,11 @@ name: Setup description: Sets up the CI environment +inputs: + moon-cache: + description: Enable moonrepo/setup-toolchain caching + default: 'true' + runs: using: 'composite' steps: @@ -11,6 +16,7 @@ runs: - name: Setup Moon uses: moonrepo/setup-toolchain@v0 with: + cache: ${{ inputs.moon-cache }} moon-version: 1.41.2 - name: Setup Node diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index 1c01c738..ce78993d 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -14,8 +14,18 @@ on: jobs: validate: - runs-on: ubuntu-latest - name: CLI Tests + strategy: + matrix: + os: + - ubuntu-latest + - windows-latest + + runs-on: ${{ matrix.os }} + name: CLI Tests (${{ matrix.os }}) + + defaults: + run: + shell: bash steps: - name: Checkout Commit diff --git a/.github/workflows/test-smoke-v2.yml b/.github/workflows/test-smoke-v2.yml index b49fc4c1..52d9ec76 100644 --- a/.github/workflows/test-smoke-v2.yml +++ b/.github/workflows/test-smoke-v2.yml @@ -9,9 +9,14 @@ on: - synchronize jobs: - validate: + validate-ubuntu: runs-on: ubuntu-latest - name: Smoke v2 Tests + name: Smoke v2 Tests (ubuntu-latest) + + defaults: + run: + shell: bash + container: image: mcr.microsoft.com/playwright:v1.57.0-noble options: --ipc=host --init @@ -54,3 +59,57 @@ jobs: DOT_LOG_LEVEL: debug LOCAL_SMOKE: 'true' run: moon smoke-v2:run-ci + + validate-windows: + runs-on: windows-latest + name: Smoke v2 Tests (windows-latest) + + defaults: + run: + shell: bash + + steps: + - name: Checkout Commit + uses: actions/checkout@v4 + with: + fetch-depth: 10 + + - name: Configure git safe.directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Checkout Main + run: | + git fetch origin + git branch -f main origin/main + + - name: Ensure xz is available + run: | + if ! command -v xz >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y xz-utils + else + apt-get update + apt-get install -y xz-utils + fi + fi + + - name: Setup + uses: ./.github/actions/setup + with: + moon-cache: 'false' + + - name: Install Playwright browser + run: pnpm --filter test-smoke-v2 exec playwright install chromium + + - name: Build Projects + run: | + moon jsx-email:build + moon create-mail:build + moon run :build --query 'project~plugin-*' + + - name: Run smoke-v2 tests + env: + DOT_LOG_LEVEL: debug + LOCAL_SMOKE: 'true' + run: moon smoke-v2:run-ci diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 60540413..37bc8962 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,8 +14,18 @@ on: jobs: validate: - runs-on: ubuntu-latest - name: Run Tests + strategy: + matrix: + os: + - ubuntu-latest + - windows-latest + + runs-on: ${{ matrix.os }} + name: Run Tests (${{ matrix.os }}) + + defaults: + run: + shell: bash steps: - name: Checkout Commit diff --git a/AGENTS.md b/AGENTS.md index 1896647c..84d31809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,17 +58,18 @@ run with `tsx`. ## Checks -Run checks through Moon (mirrors CI): +Run checks before considering work complete. ```bash moon run :lint -moon run :format moon run :typecheck moon run :test ``` If `moon` isn’t available on your PATH, run it via `./node_modules/.bin/moon` (for example `./node_modules/.bin/moon run :lint`). +There is no repo-wide Moon format task. Format changed files directly with `./node_modules/.bin/oxfmt ` before the final check pass. + `moon repo:build.all --cache off` is the canonical task to use for building (compiling) all packages. Do not run `:compile` tasks directly. `:build` tasks can be called on projects outside of packages. diff --git a/packages/create-mail/moon.yml b/packages/create-mail/moon.yml index 8cd4181a..bd251a5d 100644 --- a/packages/create-mail/moon.yml +++ b/packages/create-mail/moon.yml @@ -33,7 +33,7 @@ tasks: runDepsInParallel: false copy: - command: cp -r generators dist + command: cp -rL generators dist options: cache: false diff --git a/packages/jsx-email/src/cli/commands/build.ts b/packages/jsx-email/src/cli/commands/build.ts index bfa959e9..e6ba4c23 100644 --- a/packages/jsx-email/src/cli/commands/build.ts +++ b/packages/jsx-email/src/cli/commands/build.ts @@ -43,6 +43,12 @@ interface BuildTemplateParams { targetPath: string; } +interface RelativeOutputDirParams { + baseDir: string; + outputBasePath: string; + pathApi?: Pick; +} + interface BuildOptions { argv: BuildCommandOptions; outputBasePath?: string; @@ -64,6 +70,26 @@ export interface BuildTempatesResult extends BuildResult { fileName: string; } +export const getRelativeOutputDir = ({ + baseDir, + outputBasePath, + pathApi = isWindows ? win32 : posix +}: RelativeOutputDirParams) => { + const relativeOutputDir = pathApi.relative(outputBasePath, baseDir); + + if ( + !relativeOutputDir || + relativeOutputDir === '.' || + relativeOutputDir === '..' || + relativeOutputDir.startsWith(`..${pathApi.sep}`) || + pathApi.isAbsolute(relativeOutputDir) + ) { + return null; + } + + return relativeOutputDir; +}; + export const help = chalkTmpl` {blue email build} @@ -105,8 +131,9 @@ export const getTempPath = async (type: 'build' | 'preview') => { export const build = async (options: BuildOptions): Promise => { const { argv, outputBasePath, path, sourceFile } = options; const { html = true, out, plain, props = '{}', usePreviewProps, writeToFile = true } = argv; - const compiledPath = isWindows ? pathToFileURL(normalizePath(path)).toString() : path; - const template = await import(compiledPath); + const compiledPath = normalizePath(path); + const importPath = isWindows ? pathToFileURL(compiledPath).toString() : compiledPath; + const template = await import(importPath); // proper named export const componentExport: TemplateFn = template.Template; @@ -123,8 +150,13 @@ export const build = async (options: BuildOptions): Promise => { const templateName = basename(path, fileExt).replace(/-[^-]{8}$/, ''); const component = componentExport(renderProps); const baseDir = dirname(path); + const relativeOutputDir = outputBasePath + ? getRelativeOutputDir({ baseDir, outputBasePath }) + : null; const writePath = outputBasePath - ? join(out!, baseDir.replace(outputBasePath, ''), templateName) + ? relativeOutputDir + ? join(out!, relativeOutputDir, templateName) + : join(out!, templateName) : join(out!, templateName); // const writePath = outputBasePath // ? join(out!, baseDir.replace(outputBasePath, ''), templateName + extension) diff --git a/packages/jsx-email/src/cli/watcher.ts b/packages/jsx-email/src/cli/watcher.ts index f8579cd1..2e64bb99 100644 --- a/packages/jsx-email/src/cli/watcher.ts +++ b/packages/jsx-email/src/cli/watcher.ts @@ -1,7 +1,8 @@ // Note: Keep the star here. There are environments (ahem, Stackblitz) which // can't seem to handle the psuedo default export import { access, readFile, unlink } from 'node:fs/promises'; -import { dirname, extname, relative, resolve } from 'node:path'; +import { dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; import * as watcher from '@parcel/watcher'; import chalk from 'chalk-template'; @@ -10,36 +11,94 @@ import { type ViteDevServer } from 'vite'; import { log } from '../log.js'; -import { type BuildTempatesResult, getTempPath } from './commands/build.js'; +import { type BuildTempatesResult, getTempPath, normalizePath } from './commands/build.js'; import { type PreviewCommonParams } from './commands/types.js'; import { buildForPreview, originalCwd, writePreviewDataFiles } from './helpers.js'; +export const getParcelWatcherOptions = (platform: NodeJS.Platform = process.platform) => + platform === 'win32' ? { backend: 'windows' as const } : void 0; + interface WatchArgs { common: PreviewCommonParams; files: BuildTempatesResult[]; server: ViteDevServer; } +interface RemoveDeletedFileParams { + files: BuildTempatesResult[]; + path: string; + validFiles: string[]; +} + const exists = (path: string) => access(path).then( () => true, () => false ); +const toFilesystemPath = (path: string) => + path.startsWith('file://') ? fileURLToPath(path) : path; +const unlinkIfExists = async (path: string) => { + const fsPath = toFilesystemPath(path); + + if (!(await exists(fsPath))) return; + await unlink(fsPath); +}; // eslint-disable-next-line no-console const newline = () => console.log(''); +export const isChildPath = (parentPath: string, childPath: string) => { + const relativePath = relative(parentPath, childPath); + + return ( + !!relativePath && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) && + !isAbsolute(relativePath) + ); +}; const removeChildPaths = (paths: string[]): string[] => - paths.filter( - (p1) => !paths.some((p2) => p1 !== p2 && relative(p2, p1) && !relative(p2, p1).startsWith('..')) + paths.filter((p1) => !paths.some((p2) => p1 !== p2 && isChildPath(p2, p1))); +export const isNodeModulePath = (path: string) => + normalizePath(path).split('/').includes('node_modules'); +export const normalizeWatcherPath = (path: string) => { + const normalizedPath = normalizePath(path); + + if (/^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith('//')) { + return normalizedPath.toLowerCase(); + } + + return normalizedPath; +}; +export const removeDeletedFile = async ({ files, path, validFiles }: RemoveDeletedFileParams) => { + const normalizedPath = normalizeWatcherPath(path); + const index = files.findIndex( + ({ fileName }) => normalizeWatcherPath(fileName) === normalizedPath ); + if (index === -1) return false; + const file = files[index]; + files.splice(index, 1); + + await Promise.all([ + unlinkIfExists(file.compiledPath), + unlinkIfExists(`${file.writePathBase}.js`) + ]); + + const validFileIndex = validFiles.findIndex((fileName) => fileName === normalizedPath); + if (validFileIndex > -1) validFiles.splice(validFileIndex, 1); + + return true; +}; + const getEntrypoints = async (files: BuildTempatesResult[]) => { const entrypoints: Set = new Set(); const promises = files.map(async ({ metaPath }) => { - log.debug({ exists: await exists(metaPath ?? ''), metaPath }); + const fsMetaPath = metaPath ? toFilesystemPath(metaPath) : null; + + log.debug({ exists: await exists(fsMetaPath ?? ''), metaPath }); - if (!metaPath || !(await exists(metaPath))) return null; - const contents = await readFile(metaPath, 'utf-8'); + if (!fsMetaPath || !(await exists(fsMetaPath))) return null; + const contents = await readFile(fsMetaPath, 'utf-8'); const metafile = JSON.parse(contents) as Metafile; Object.entries(metafile.outputs).forEach(([_, { entryPoint }]) => { @@ -73,10 +132,12 @@ const getWatchDirectories = async (files: BuildTempatesResult[], depPaths: strin const mapDeps = async (files: BuildTempatesResult[]) => { const depPaths: string[] = []; const metaReads = files.map(async ({ metaPath }) => { - log.debug({ exists: await exists(metaPath ?? ''), metaPath }); + const fsMetaPath = metaPath ? toFilesystemPath(metaPath) : null; - if (!metaPath || !(await exists(metaPath))) return null; - const contents = await readFile(metaPath, 'utf-8'); + log.debug({ exists: await exists(fsMetaPath ?? ''), metaPath }); + + if (!fsMetaPath || !(await exists(fsMetaPath))) return null; + const contents = await readFile(fsMetaPath, 'utf-8'); const metafile = JSON.parse(contents) as Metafile; const { outputs } = metafile; const result = new Map>(); @@ -102,6 +163,20 @@ const mapDeps = async (files: BuildTempatesResult[]) => { return { depPaths, deps }; }; +const mergeTemplateDeps = ( + templateDeps: Map>, + deps: Array> | null> +) => { + for (const map of deps) { + map?.forEach((value, key) => { + const normalizedKey = normalizeWatcherPath(key); + const set = templateDeps.get(normalizedKey) ?? new Set(); + + value.forEach((entrypoint) => set.add(entrypoint)); + templateDeps.set(normalizedKey, set); + }); + } +}; export const watch = async (args: WatchArgs) => { newline(); @@ -111,17 +186,17 @@ export const watch = async (args: WatchArgs) => { const { argv } = common; const extensions = ['.css', '.js', '.jsx', '.ts', '.tsx']; const { depPaths, deps: metaDeps } = await mapDeps(files); - const dependencyPaths = depPaths.filter((path) => !path.includes('/node_modules/')); + const dependencyPaths = depPaths.filter((path) => !isNodeModulePath(path)); const { entrypoints, watchPaths: watchDirectories } = await getWatchDirectories( files, dependencyPaths ); const templateDeps = new Map>(); - const validFiles = Array.from(new Set([...entrypoints, ...dependencyPaths])); + const validFiles = Array.from( + new Set([...entrypoints, ...dependencyPaths].map(normalizeWatcherPath)) + ); - for (const map of metaDeps) { - map!.forEach((value, key) => templateDeps.set(key, value)); - } + mergeTemplateDeps(templateDeps, metaDeps); log.info({ validFiles }); @@ -132,8 +207,8 @@ export const watch = async (args: WatchArgs) => { // the event path is in the set of files we want to watch, unless it's a create // event const events = incoming.filter((event) => { - if (event.path.includes('/node_modules/')) return false; - if (event.type !== 'create') return validFiles.includes(event.path); + if (isNodeModulePath(event.path)) return false; + if (event.type !== 'create') return validFiles.includes(normalizeWatcherPath(event.path)); return true; }); @@ -142,7 +217,7 @@ export const watch = async (args: WatchArgs) => { .map((e) => e.path) .filter((path) => extensions.includes(extname(path))); const changedTemplates = changedFiles - .flatMap((file) => [...(templateDeps.get(file) || [])]) + .flatMap((file) => [...(templateDeps.get(normalizeWatcherPath(file)) || [])]) .filter(Boolean); const createdFiles = events .filter((event) => event.type === 'create') @@ -169,18 +244,7 @@ export const watch = async (args: WatchArgs) => { '\n' ); - deletedFiles.forEach((path) => { - let index: any = files.findIndex(({ fileName }) => path === fileName); - if (index === -1) return; - const file = files[index]; - files.splice(index, 1); - // Note: Don't await either, we don't need to - unlink(file.compiledPath); - unlink(`${file.writePathBase}.js`); - - index = validFiles.find((fileName) => path === fileName); - if (index > -1) validFiles.splice(index, 1); - }); + await Promise.all(deletedFiles.map((path) => removeDeletedFile({ files, path, validFiles }))); } if (createdFiles.length) { @@ -203,9 +267,10 @@ export const watch = async (args: WatchArgs) => { const mappedDeps = await mapDeps(results); files.push(...results); + mergeTemplateDeps(templateDeps, mappedDeps.deps); validFiles.push( - path, - ...mappedDeps.depPaths.filter((p) => !p.includes('/node_modules/')) + normalizeWatcherPath(path), + ...mappedDeps.depPaths.filter((p) => !isNodeModulePath(p)).map(normalizeWatcherPath) ); await writePreviewDataFiles(results); @@ -223,15 +288,24 @@ export const watch = async (args: WatchArgs) => { '\n' ); - changedTemplates.forEach(async (path) => { - const results = await buildForPreview({ buildPath, exclude, quiet: true, targetPath: path }); - await writePreviewDataFiles(results); - }); + await Promise.all( + changedTemplates.map(async (path) => { + const results = await buildForPreview({ + buildPath, + exclude, + quiet: true, + targetPath: path + }); + await writePreviewDataFiles(results); + }) + ); }; log.debug('Watching Paths:', watchDirectories.sort()); - const subPromises = watchDirectories.map((path) => watcher.subscribe(path, handler)); + const subPromises = watchDirectories.map((path) => + watcher.subscribe(path, handler, getParcelWatcherOptions()) + ); const subscriptions = await Promise.all(subPromises); server.httpServer!.on('close', () => { diff --git a/packages/jsx-email/test/cli/build-relative-output-dir.test.ts b/packages/jsx-email/test/cli/build-relative-output-dir.test.ts new file mode 100644 index 00000000..8d6a866d --- /dev/null +++ b/packages/jsx-email/test/cli/build-relative-output-dir.test.ts @@ -0,0 +1,47 @@ +import { win32 } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { getRelativeOutputDir } from '../../src/cli/commands/build.js'; + +describe('cli build output path', () => { + it('returns a nested relative path for Windows-style temp output paths', () => { + const relativePath = getRelativeOutputDir({ + baseDir: 'C:\\Users\\batman\\AppData\\Local\\Temp\\jsx-email\\build\\src\\emails', + outputBasePath: 'C:/Users/batman/AppData/Local/Temp/jsx-email/build', + pathApi: win32 + }); + + expect(relativePath).toBe('src\\emails'); + }); + + it('returns null for different Windows drives', () => { + const relativePath = getRelativeOutputDir({ + baseDir: 'D:\\Users\\batman\\AppData\\Local\\Temp\\jsx-email\\build\\src\\emails', + outputBasePath: 'C:/Users/batman/AppData/Local/Temp/jsx-email/build', + pathApi: win32 + }); + + expect(relativePath).toBeNull(); + }); + + it('returns null when the baseDir escapes the output base path', () => { + const relativePath = getRelativeOutputDir({ + baseDir: 'C:\\Users\\batman\\other\\templates', + outputBasePath: 'C:/Users/batman/AppData/Local/Temp/jsx-email/build', + pathApi: win32 + }); + + expect(relativePath).toBeNull(); + }); + + it('allows child directories that start with two dots', () => { + const relativePath = getRelativeOutputDir({ + baseDir: 'C:\\Users\\batman\\AppData\\Local\\Temp\\jsx-email\\build\\..templates', + outputBasePath: 'C:/Users/batman/AppData/Local/Temp/jsx-email/build', + pathApi: win32 + }); + + expect(relativePath).toBe('..templates'); + }); +}); diff --git a/packages/jsx-email/test/cli/watcher.test.ts b/packages/jsx-email/test/cli/watcher.test.ts new file mode 100644 index 00000000..83928634 --- /dev/null +++ b/packages/jsx-email/test/cli/watcher.test.ts @@ -0,0 +1,521 @@ +import { EventEmitter } from 'node:events'; +import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { BuildTempatesResult } from '../../src/cli/commands/build.js'; + +const mocks = vi.hoisted(() => ({ + buildForPreview: vi.fn(), + originalCwd: '/repo', + subscriptions: [] as Array<{ unsubscribe: ReturnType }>, + subscribe: vi.fn(), + writePreviewDataFiles: vi.fn() +})); + +vi.mock('@parcel/watcher', () => ({ + subscribe: mocks.subscribe +})); + +vi.mock('../../src/cli/helpers.js', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + buildForPreview: mocks.buildForPreview, + originalCwd: mocks.originalCwd, + writePreviewDataFiles: mocks.writePreviewDataFiles + }; +}); + +import { + getParcelWatcherOptions, + isChildPath, + isNodeModulePath, + normalizeWatcherPath, + removeDeletedFile, + watch +} from '../../src/cli/watcher.js'; + +const fileExists = (path: string) => + access(path).then( + () => true, + () => false + ); +const createSubscription = () => { + const subscription = { unsubscribe: vi.fn() }; + mocks.subscriptions.push(subscription); + + return subscription; +}; +const repoPath = (...parts: string[]) => resolve(mocks.originalCwd, ...parts); +const createMetaFile = async ( + tempDir: string, + name: string, + entryPoint: string, + inputs: string[] +) => { + const metaPath = join(tempDir, `${name}.meta.json`); + + await writeFile( + metaPath, + JSON.stringify({ + outputs: { + [`${name}.js`]: { + entryPoint, + inputs: Object.fromEntries(inputs.map((input) => [input, {}])) + } + } + }), + 'utf8' + ); + + return metaPath; +}; +const createBuildResult = ({ + compiledPath = '/preview/base.js', + fileName, + metaPath, + templateName = 'Base', + writePathBase = '/preview/base' +}: { + compiledPath?: string; + fileName: string; + metaPath?: string; + templateName?: string; + writePathBase?: string; +}): BuildTempatesResult => ({ + compiledPath, + fileName, + html: '', + metaPath, + plainText: '', + sourceFile: fileName, + templateName, + writePathBase +}); +const createWatchArgs = (files: BuildTempatesResult[]) => { + const httpServer = new EventEmitter(); + + return { + common: { + argv: { + exclude: [] + } + }, + files, + httpServer, + server: { + httpServer + } + }; +}; +const captureWatcherHandler = () => { + let handler: Parameters[1] | undefined; + + mocks.subscribe.mockImplementation(async (_, callback) => { + handler = callback; + return createSubscription(); + }); + + return () => handler; +}; + +beforeEach(() => { + mocks.buildForPreview.mockReset(); + mocks.subscribe.mockReset(); + mocks.subscriptions.length = 0; + mocks.writePreviewDataFiles.mockReset(); + + mocks.subscribe.mockImplementation(async () => createSubscription()); + mocks.buildForPreview.mockResolvedValue([]); + mocks.writePreviewDataFiles.mockResolvedValue(undefined); +}); + +describe('watcher path helpers', () => { + it('uses the native Windows parcel watcher backend on Windows', () => { + expect(getParcelWatcherOptions('win32')).toEqual({ backend: 'windows' }); + }); + + it('does not force a parcel watcher backend on non-Windows platforms', () => { + expect(getParcelWatcherOptions('darwin')).toBeUndefined(); + expect(getParcelWatcherOptions('linux')).toBeUndefined(); + }); + + it('detects POSIX node_modules paths', () => { + expect(isNodeModulePath('/repo/node_modules/pkg/file.js')).toBe(true); + }); + + it('detects Windows node_modules paths', () => { + expect(isNodeModulePath('C:\\repo\\node_modules\\pkg\\file.js')).toBe(true); + }); + + it('does not match node_modules as part of another path segment', () => { + expect(isNodeModulePath('/repo/not_node_modules/pkg/file.js')).toBe(false); + }); + + it('normalizes Windows drive paths for watcher lookups', () => { + expect(normalizeWatcherPath('C:\\Repo\\Templates\\Base.tsx')).toBe( + 'c:/repo/templates/base.tsx' + ); + }); + + it('normalizes Windows UNC paths for watcher lookups', () => { + expect(normalizeWatcherPath('\\\\Server\\Share\\Templates\\Base.tsx')).toBe( + '//server/share/templates/base.tsx' + ); + }); + + it('preserves POSIX path casing for watcher lookups', () => { + expect(normalizeWatcherPath('/Repo/Templates/Base.tsx')).toBe('/Repo/Templates/Base.tsx'); + }); + + it('detects child paths', () => { + expect(isChildPath('/repo/templates', '/repo/templates/nested')).toBe(true); + }); + + it('does not treat sibling directories that start with two dots as child paths', () => { + expect(isChildPath('/repo/templates', '/repo/..templates')).toBe(false); + }); + + it('does not treat Windows cross-drive paths as child paths', () => { + expect(isChildPath('C:\\repo\\templates', 'D:\\repo\\templates\\nested')).toBe(false); + }); + + it('removes deleted files by normalized watcher path and cleans artifacts', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + const compiledPath = join(tempDir, 'base.js'); + const writePathBase = join(tempDir, 'preview-base'); + const previewDataPath = `${writePathBase}.js`; + const fileName = 'C:\\Repo\\Templates\\Base.tsx'; + const files = [ + { + compiledPath, + fileName, + html: '', + plainText: '', + sourceFile: fileName, + templateName: 'Base', + writePathBase + } + ] satisfies BuildTempatesResult[]; + const validFiles = [normalizeWatcherPath(fileName)]; + + await writeFile(compiledPath, ''); + await writeFile(previewDataPath, ''); + + try { + await expect( + removeDeletedFile({ + files, + path: 'c:/repo/templates/base.tsx', + validFiles + }) + ).resolves.toBe(true); + + expect(files).toEqual([]); + expect(validFiles).toEqual([]); + await expect(fileExists(compiledPath)).resolves.toBe(false); + await expect(fileExists(previewDataPath)).resolves.toBe(false); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); +}); + +describe('watcher integration', () => { + it('subscribes to deduped parent directories and excludes node_modules dependencies', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const templatesDir = dirname(baseTemplate); + const metaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx', + 'templates/components/button.tsx', + 'templates/nested/component.tsx', + 'node_modules/pkg/index.js' + ]); + const args = createWatchArgs([createBuildResult({ fileName: baseTemplate, metaPath })]); + + await watch(args); + + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith( + templatesDir, + expect.any(Function), + getParcelWatcherOptions() + ); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('subscribes when build metadata paths are file URLs', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const templatesDir = dirname(baseTemplate); + const metaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx' + ]); + const args = createWatchArgs([ + createBuildResult({ + fileName: baseTemplate, + metaPath: pathToFileURL(metaPath).toString() + }) + ]); + + await watch(args); + + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith( + templatesDir, + expect.any(Function), + getParcelWatcherOptions() + ); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('unsubscribes every parcel watcher subscription when the Vite server closes', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const accountTemplate = repoPath('account/email.tsx'); + const baseMetaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx' + ]); + const accountMetaPath = await createMetaFile(tempDir, 'account', 'account/email.tsx', [ + 'account/email.tsx' + ]); + const args = createWatchArgs([ + createBuildResult({ fileName: baseTemplate, metaPath: baseMetaPath }), + createBuildResult({ + fileName: accountTemplate, + metaPath: accountMetaPath, + templateName: 'Account', + writePathBase: repoPath('preview/account') + }) + ]); + + await watch(args); + args.httpServer.emit('close'); + + expect(mocks.subscriptions).toHaveLength(2); + expect(mocks.subscriptions.map(({ unsubscribe }) => unsubscribe.mock.calls.length)).toEqual([ + 1, 1 + ]); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('rebuilds the owning template when a tracked dependency updates', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const buttonComponent = repoPath('templates/components/button.tsx'); + const metaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx', + 'templates/components/button.tsx' + ]); + const args = createWatchArgs([createBuildResult({ fileName: baseTemplate, metaPath })]); + + const getHandler = captureWatcherHandler(); + + await watch(args); + await getHandler()?.(null, [{ path: buttonComponent, type: 'update' }]); + + expect(mocks.buildForPreview).toHaveBeenCalledWith({ + buildPath: expect.stringContaining('preview'), + exclude: [], + quiet: true, + targetPath: baseTemplate + }); + expect(mocks.writePreviewDataFiles).toHaveBeenCalledWith([]); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('ignores untracked updates, node_modules events, and unsupported extensions', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const metaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx' + ]); + const args = createWatchArgs([createBuildResult({ fileName: baseTemplate, metaPath })]); + + const getHandler = captureWatcherHandler(); + + await watch(args); + await getHandler()?.(null, [ + { path: repoPath('templates/readme.md'), type: 'update' }, + { path: repoPath('templates/other.tsx'), type: 'update' }, + { path: repoPath('node_modules/pkg/index.ts'), type: 'update' } + ]); + + expect(mocks.buildForPreview).not.toHaveBeenCalled(); + expect(mocks.writePreviewDataFiles).not.toHaveBeenCalled(); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('builds created template files and tracks their dependency paths', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const createdTemplate = repoPath('templates/new.tsx'); + const createdButton = repoPath('templates/components/new-button.tsx'); + const baseMetaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx' + ]); + const createdMetaPath = await createMetaFile(tempDir, 'created', 'templates/new.tsx', [ + 'templates/new.tsx', + 'templates/components/new-button.tsx' + ]); + const createdResult = createBuildResult({ + fileName: createdTemplate, + metaPath: createdMetaPath, + templateName: 'New', + writePathBase: repoPath('preview/new') + }); + const args = createWatchArgs([ + createBuildResult({ fileName: baseTemplate, metaPath: baseMetaPath }) + ]); + + const getHandler = captureWatcherHandler(); + mocks.buildForPreview.mockResolvedValueOnce([createdResult]).mockResolvedValueOnce([]); + + await watch(args); + await getHandler()?.(null, [{ path: createdTemplate, type: 'create' }]); + await getHandler()?.(null, [{ path: createdButton, type: 'update' }]); + + expect(mocks.buildForPreview).toHaveBeenNthCalledWith(1, { + buildPath: expect.stringContaining('preview'), + exclude: [], + quiet: true, + targetPath: createdTemplate + }); + expect(mocks.buildForPreview).toHaveBeenNthCalledWith(2, { + buildPath: expect.stringContaining('preview'), + exclude: [], + quiet: true, + targetPath: createdTemplate + }); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('handles duplicate Windows update events without rebuilding unrelated templates', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const accountTemplate = repoPath('account/email.tsx'); + const baseMetaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx' + ]); + const accountMetaPath = await createMetaFile(tempDir, 'account', 'account/email.tsx', [ + 'account/email.tsx' + ]); + const args = createWatchArgs([ + createBuildResult({ fileName: baseTemplate, metaPath: baseMetaPath }), + createBuildResult({ + fileName: accountTemplate, + metaPath: accountMetaPath, + templateName: 'Account', + writePathBase: repoPath('preview/account') + }) + ]); + + const getHandler = captureWatcherHandler(); + + await watch(args); + await getHandler()?.(null, [ + { path: baseTemplate, type: 'update' }, + { path: baseTemplate, type: 'update' } + ]); + + expect(mocks.buildForPreview).toHaveBeenCalledTimes(2); + expect(mocks.buildForPreview).toHaveBeenCalledWith( + expect.objectContaining({ targetPath: baseTemplate }) + ); + expect(mocks.buildForPreview).not.toHaveBeenCalledWith( + expect.objectContaining({ targetPath: accountTemplate }) + ); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('removes deleted tracked files and compiled preview artifacts through the watcher handler', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + const compiledPath = join(tempDir, 'base.js'); + const writePathBase = join(tempDir, 'preview-base'); + const previewDataPath = `${writePathBase}.js`; + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const metaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx' + ]); + const args = createWatchArgs([ + createBuildResult({ + compiledPath, + fileName: baseTemplate, + metaPath, + writePathBase + }) + ]); + const getHandler = captureWatcherHandler(); + + await writeFile(compiledPath, 'compiled', 'utf8'); + await writeFile(previewDataPath, 'preview', 'utf8'); + + await watch(args); + await getHandler()?.(null, [{ path: baseTemplate, type: 'delete' }]); + await getHandler()?.(null, [{ path: baseTemplate, type: 'update' }]); + + expect(args.files).toEqual([]); + await expect(fileExists(compiledPath)).resolves.toBe(false); + await expect(fileExists(previewDataPath)).resolves.toBe(false); + expect(mocks.buildForPreview).not.toHaveBeenCalled(); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); + + it('does not process events when parcel watcher reports an error', async () => { + const tempDir = await mkdtemp(join(os.tmpdir(), 'jsx-email-watcher-test-')); + + try { + const baseTemplate = repoPath('templates/base.tsx'); + const metaPath = await createMetaFile(tempDir, 'base', 'templates/base.tsx', [ + 'templates/base.tsx' + ]); + const args = createWatchArgs([createBuildResult({ fileName: baseTemplate, metaPath })]); + const getHandler = captureWatcherHandler(); + + await watch(args); + await getHandler()?.(new Error('watcher failed'), []); + + expect(mocks.buildForPreview).not.toHaveBeenCalled(); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/ci-preview-setup-smoke-v2.sh b/scripts/ci-preview-setup-smoke-v2.sh index b7115024..b45d1543 100755 --- a/scripts/ci-preview-setup-smoke-v2.sh +++ b/scripts/ci-preview-setup-smoke-v2.sh @@ -8,7 +8,7 @@ set -euo pipefail # up in a separate directory alleviates those differences and more closesly represents a user's machine # which provides a more accurate test environment -TMP_ROOT=${TMPDIR:-"/tmp"} +TMP_ROOT=$(node -p "require('node:os').tmpdir().replace(/\\\\/g, '/')") TESTS_DIR="${TMP_ROOT%/}/jsx-email-tests" PROJECT_DIR_NAME='smoke-v2' STATE_PATH=${SMOKE_V2_STATE_PATH:-"${TMP_ROOT%/}/jsx-email-smoke-v2.state"} @@ -24,7 +24,7 @@ mv -f "$PROJECT_DIR_NAME" "$TESTS_DIR/$PROJECT_DIR_NAME" cd "$TESTS_DIR/$PROJECT_DIR_NAME" -REPO_PACKAGE_MANAGER=$(node -p "require('$REPO_DIR/package.json').packageManager") +REPO_PACKAGE_MANAGER=$(REPO_DIR="$REPO_DIR" node -p "require(require('node:path').join(process.env.REPO_DIR, 'package.json')).packageManager") REPO_PACKAGE_MANAGER="$REPO_PACKAGE_MANAGER" node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.packageManager=process.env.REPO_PACKAGE_MANAGER; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');" @@ -60,9 +60,10 @@ JSX_EMAIL_TARBALL="$JSX_EMAIL_TARBALL" \ REPO_DIR="$REPO_DIR" \ node - <<'EOF' const fs = require('node:fs'); +const path = require('node:path'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); -const repoPkg = JSON.parse(fs.readFileSync(`${process.env.REPO_DIR}/package.json`, 'utf8')); +const repoPkg = JSON.parse(fs.readFileSync(path.join(process.env.REPO_DIR, 'package.json'), 'utf8')); const repoOverrides = repoPkg.pnpm?.overrides ?? {}; pkg.dependencies = { diff --git a/scripts/ci-preview-start-smoke-v2.sh b/scripts/ci-preview-start-smoke-v2.sh index c6765cca..f3426e96 100755 --- a/scripts/ci-preview-start-smoke-v2.sh +++ b/scripts/ci-preview-start-smoke-v2.sh @@ -8,9 +8,9 @@ set -euo pipefail # up in a separate directory alleviates those differences and more closesly represents a user's machine # which provides a more accurate test environment -TMP_ROOT=${TMPDIR:-"/tmp"} +TMP_ROOT=$(node -p "require('node:os').tmpdir().replace(/\\\\/g, '/')") STATE_PATH=${SMOKE_V2_STATE_PATH:-"${TMP_ROOT%/}/jsx-email-smoke-v2.state"} SMOKE_DIR=$(cat "$STATE_PATH") cd "$SMOKE_DIR" -pnpm exec email preview fixtures/templates --no-open --port 55420 +exec ./node_modules/.bin/email preview fixtures/templates --no-open --port 55420 diff --git a/test/cli/create-mail.test.ts b/test/cli/create-mail.test.ts index 58e9c234..8b017b39 100644 --- a/test/cli/create-mail.test.ts +++ b/test/cli/create-mail.test.ts @@ -7,20 +7,27 @@ import strip from 'strip-ansi'; process.chdir(__dirname); +const normalizePathSeparators = (value: string) => value.replaceAll('\\', '/'); + describe('create-mail', async () => { test('command', async () => { const { stdout } = await execa({ cwd: __dirname, + env: { + IS_CLI_TEST: 'true' + }, shell: true // Note: For some reason `pnpm exec` is fucking with our CWD, and resets it to // packages/jsx-email, which causes the config not to be found. so we use npx instead - })`IS_CLI_TEST=true create-mail .test/new --yes`; - const plain = strip(stdout) - .replace(/^(.*)create-mail/, 'create-mail') - .replace(/v(\d+\.\d+\.\d+)/, '') - .split('\n') - .map((line) => line.trimEnd()) - .join('\n'); + })`create-mail .test/new --yes`; + const plain = normalizePathSeparators( + strip(stdout) + .replace(/^(.*)create-mail/, 'create-mail') + .replace(/v(\d+\.\d+\.\d+)/, '') + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + ); expect(plain).toMatchSnapshot(); @@ -44,7 +51,25 @@ describe('create-mail', async () => { } `); - const files = await globby('.test/new/**/*', { dot: true }); + const files = (await globby('.test/new/**/*', { dot: true })) + .map(normalizePathSeparators) + .sort((left, right) => { + const depthDifference = left.split('/').length - right.split('/').length; + + if (depthDifference !== 0) { + return depthDifference; + } + + if (left < right) { + return -1; + } + + if (left > right) { + return 1; + } + + return 0; + }); expect(files).toMatchSnapshot(); }); diff --git a/test/smoke-v2/playwright.config.ts b/test/smoke-v2/playwright.config.ts index fce78d5f..b57ab973 100644 --- a/test/smoke-v2/playwright.config.ts +++ b/test/smoke-v2/playwright.config.ts @@ -1,8 +1,13 @@ /* eslint-disable import/no-default-export */ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { defineConfig, devices } from '@playwright/test'; // Note: https://playwright.dev/docs/test-configuration. +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + export default defineConfig({ forbidOnly: !!process.env.CI, fullyParallel: true, @@ -23,7 +28,8 @@ export default defineConfig({ trace: 'on-first-retry' }, webServer: { - command: 'moon smoke-v2:start', + command: 'bash ./scripts/ci-preview-start-smoke-v2.sh', + cwd: repoRoot, env: { ENV_TEST_VALUE: 'joker' }, diff --git a/test/smoke-v2/tests/smoke-v2.test.ts b/test/smoke-v2/tests/smoke-v2.test.ts index 73ce8f8e..d537a535 100644 --- a/test/smoke-v2/tests/smoke-v2.test.ts +++ b/test/smoke-v2/tests/smoke-v2.test.ts @@ -8,8 +8,9 @@ import { expect, test, type Page } from '@playwright/test'; import { getHTML } from './helpers/html.js'; const timeout = { timeout: 15e3 }; -const defaultStatePath = join(os.tmpdir(), 'jsx-email-smoke-v2.state'); -const defaultPreviewBuildFilePath = join(os.tmpdir(), 'jsx-email', 'preview', 'base.js'); +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 templates = [ { buttonName: 'Base', snapshotName: 'Base' }, { buttonName: 'Code', snapshotName: 'Code' },