diff --git a/tests/scenarios/v2-addon-dev-watch-test.ts b/tests/scenarios/v2-addon-dev-watch-test.ts new file mode 100644 index 000000000..d5d347e28 --- /dev/null +++ b/tests/scenarios/v2-addon-dev-watch-test.ts @@ -0,0 +1,329 @@ +import path from 'path'; +import { baseV2Addon } from './scenarios'; +import { PreparedApp, Scenarios } from 'scenario-tester'; +import fs from 'fs/promises'; +import { spawn } from 'child_process'; +import QUnit from 'qunit'; +import merge from 'lodash/merge'; + +const { module: Qmodule, test } = QUnit; + +Scenarios.fromProject(() => baseV2Addon()) + .map('v2-addon-dev-watch', async addon => { + addon.pkg.name = 'v2-addon'; + addon.pkg.files = ['dist']; + addon.pkg.exports = { + './*': './dist/*.js', + './addon-main.js': './addon-main.js', + './package.json': './package.json', + }; + addon.pkg.scripts = { + build: 'node ./node_modules/rollup/dist/bin/rollup -c ./rollup.config.mjs', + start: 'node ./node_modules/rollup/dist/bin/rollup -c ./rollup.config.mjs --watch --no-watch.clearScreen', + }; + + merge(addon.files, { + 'babel.config.json': ` + { + "presets": [ + ["@babel/preset-env"] + ], + "plugins": [ + "@embroider/addon-dev/template-colocation-plugin", + ["@babel/plugin-proposal-decorators", { "legacy": true }], + [ "@babel/plugin-proposal-class-properties" ] + ] + } + `, + 'rollup.config.mjs': ` + import { babel } from '@rollup/plugin-babel'; + import { Addon } from '@embroider/addon-dev/rollup'; + + const addon = new Addon({ + srcDir: 'src', + destDir: 'dist', + }); + + export default { + output: addon.output(), + + plugins: [ + addon.publicEntrypoints(['components/**/*.js']), + addon.appReexports(['components/**/*.js']), + addon.hbs(), + addon.dependencies(), + addon.publicAssets('custom-public'), + + babel({ babelHelpers: 'bundled' }), + + addon.clean(), + ], + }; + `, + 'custom-public': { + 'demo.css': `button { color: red; }`, + 'index.css': `button { color: green; }`, + }, + src: { + components: { + 'button.hbs': ` + + `, + 'out.hbs': `{{yield}}`, + 'demo.js': ` + import Component from '@glimmer/component'; + import { tracked } from '@glimmer/tracking'; + + import FlipButton from './button'; + import Out from './out'; + + export default class ExampleComponent extends Component { + Button = FlipButton; + Out = Out; + + @tracked active = false; + + flip = () => (this.active = !this.active); + } + `, + 'demo.hbs': `Hello there! + {{this.active}} + + + `, + }, + }, + }); + + addon.linkDependency('@embroider/addon-shim', { baseDir: __dirname }); + addon.linkDependency('@embroider/addon-dev', { baseDir: __dirname }); + addon.linkDependency('babel-plugin-ember-template-compilation', { baseDir: __dirname }); + addon.linkDevDependency('@babel/core', { baseDir: __dirname }); + addon.linkDevDependency('@babel/plugin-proposal-class-properties', { baseDir: __dirname }); + addon.linkDevDependency('@babel/plugin-proposal-decorators', { baseDir: __dirname }); + addon.linkDevDependency('@babel/preset-env', { baseDir: __dirname }); + addon.linkDevDependency('@rollup/plugin-babel', { baseDir: __dirname }); + addon.linkDevDependency('rollup', { baseDir: __dirname }); + }) + .forEachScenario(scenario => { + Qmodule(scenario.name, function (hooks) { + let addon: PreparedApp; + let watcher: DevWatcher | undefined; + + hooks.before(async () => { + addon = await scenario.prepare(); + + // run the build *once* so we have a known stable state + await addon.execute('pnpm build'); + }); + + hooks.beforeEach(function (assert) { + // None of these tests should take longer than even 1s, but + // if something goes wrong, they could hang, and we don't want to hold up + // all of C.I. + assert.timeout(5_000); + }); + + hooks.afterEach(async () => { + watcher?.stop(); + }); + + Qmodule('Watching the addon via rollup -c -w', function () { + test('the package.json is not updated since it would be the same', async function (assert) { + watcher = new DevWatcher(addon); + + await watcher.start(); + + let someFile = path.join(addon.dir, 'src/components/demo.hbs'); + let manifestPath = path.join(addon.dir, 'package.json'); + + await isNotModified({ + filePath: manifestPath, + assert, + // Update a component + fn: async () => { + let someContent = await fs.readFile(someFile); + + // generally it's bad to introduce time dependencies to a test, but we need to wait long enough + // to guess for how long it'll take for the file system to update our file. + // + // the `stat` is measured in `ms`, so it's still pretty fast + await aBit(10); + await fs.writeFile(someFile, someContent + `\n`); + }, + }); + }); + + test('the package.json *is* updated, since app-js changed', async function (assert) { + watcher = new DevWatcher(addon); + + await watcher.start(); + + let manifestPath = path.join(addon.dir, 'package.json'); + + await becomesModified({ + filePath: manifestPath, + assert, + // Remove a component + fn: async () => { + // generally it's bad to introduce time dependencies to a test, but we need to wait long enough + // to guess for how long it'll take for the file system to update our file. + // + // the `stat` is measured in `ms`, so it's still pretty fast + await aBit(10); + await fs.rm(path.join(addon.dir, 'src/components/demo.js')); + await fs.rm(path.join(addon.dir, 'src/components/demo.hbs')); + + await watcher?.settled(); + }, + }); + }); + + test('the package.json *is* updated, since public assets changed', async function (assert) { + let someFile = path.join(addon.dir, 'custom-public/demo.css'); + let manifestPath = path.join(addon.dir, 'package.json'); + + await becomesModified({ + filePath: manifestPath, + assert, + // Delete a publicAsset + fn: async () => { + await fs.rm(someFile); + // publicAssets are not watched, as they are not part of The Module Graphâ„¢ + await addon.execute('pnpm build'); + }, + }); + }); + }); + }); + }); + +class DevWatcher { + #addon: PreparedApp; + #singletonAbort?: AbortController; + #waitForBuildPromise?: Promise; + #lastBuild?: string; + + constructor(addon: PreparedApp) { + this.#addon = addon; + } + + start = () => { + if (this.#singletonAbort) this.#singletonAbort.abort(); + + this.#singletonAbort = new AbortController(); + + /** + * NOTE: when running rollup in a non-TTY environemnt, the "watching for changes" message does not print. + */ + let rollupProcess = spawn('pnpm', ['start'], { + cwd: this.#addon.dir, + signal: this.#singletonAbort.signal, + stdio: ['pipe', 'pipe', 'pipe'], + // Have to disable color so our regex / string matching works easier + // Have to include process.env, so the spawned environment has access to `pnpm` + env: { ...process.env, NO_COLOR: '1' }, + }); + + let settle: (...args: unknown[]) => void; + let error: (...args: unknown[]) => void; + this.#waitForBuildPromise = new Promise((resolve, reject) => { + settle = resolve; + error = reject; + }); + + if (!rollupProcess.stdout) { + throw new Error(`Failed to start process, pnpm start`); + } + if (!rollupProcess.stderr) { + throw new Error(`Failed to start process, pnpm start`); + } + + let handleData = (data: Buffer) => { + let string = data.toString(); + let lines = string.split('\n'); + + let build = lines.find(line => line.trim().match(/^created dist in (.+)$/)); + let problem = lines.find(line => line.includes('Error:')); + let isAbort = lines.find(line => line.includes('AbortError:')); + + if (isAbort) { + // Test may have ended, we want to kill the watcher, + // but not error, because throwing an error causes the test to fail. + return settle(); + } + + if (problem) { + console.error('\n!!!\n', problem, '\n!!!\n'); + error(problem); + return; + } + + if (build) { + this.#lastBuild = build[1]; + + settle?.(); + + this.#waitForBuildPromise = new Promise((resolve, reject) => { + settle = resolve; + error = reject; + }); + } + }; + + // NOTE: rollup outputs to stderr only, not stdout + rollupProcess.stderr.on('data', (...args) => handleData(...args)); + rollupProcess.on('error', handleData); + rollupProcess.on('close', () => settle?.()); + rollupProcess.on('exit', () => settle?.()); + + return this.#waitForBuildPromise; + }; + + stop = () => this.#singletonAbort?.abort(); + settled = () => this.#waitForBuildPromise; + get lastBuild() { + return this.#lastBuild; + } +} +async function becomesModified({ + filePath, + assert, + fn, +}: { + filePath: string; + assert: Assert; + fn: () => Promise; +}) { + let oldStat = (await fs.stat(filePath)).mtimeMs; + + await fn(); + + let newStat = (await fs.stat(filePath)).mtimeMs; + + assert.notStrictEqual( + oldStat, + newStat, + `Expected ${filePath} to be modified. Latest: ${newStat}, previously: ${oldStat}` + ); +} + +async function isNotModified({ filePath, assert, fn }: { filePath: string; assert: Assert; fn: () => Promise }) { + let oldStat = (await fs.stat(filePath)).mtimeMs; + + await fn(); + + let newStat = (await fs.stat(filePath)).mtimeMs; + + assert.strictEqual( + oldStat, + newStat, + `Expected ${filePath} to be unchanged. Latest: ${newStat}, and pre-fn: ${oldStat}` + ); +} + +function aBit(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +}