diff --git a/tests/scenarios/helpers.ts b/tests/scenarios/helpers.ts index e8af6adba..82d4b232f 100644 --- a/tests/scenarios/helpers.ts +++ b/tests/scenarios/helpers.ts @@ -1,66 +1 @@ -import { PreparedApp } from 'scenario-tester'; -import { join } from 'path'; -import { readFileSync } from 'fs'; -import globby from 'globby'; -import { set } from 'lodash'; -import type { JSDOM } from 'jsdom'; - -export interface FastbootTestHelpers { - visit(url: string): Promise; - fetchAsset(url: string): Promise; -} - -export async function setupFastboot( - app: PreparedApp, - environment = 'development', - envVars?: Record -): Promise { - let result = await app.execute(`node node_modules/ember-cli/bin/ember build --environment=${environment}`, { - env: envVars, - }); - - if (result.exitCode !== 0) { - throw new Error(`failed to build app for fastboot: ${result.output}`); - } - - const FastBoot = require('fastboot'); - - let fastboot = new FastBoot({ - distPath: join(app.dir, 'dist'), - resilient: false, - }); - - async function visit(url: string) { - const jsdom = require('jsdom'); - const { JSDOM } = jsdom; - let visitOpts = { - request: { headers: { host: 'localhost:4200' } }, - }; - let page = await fastboot.visit(url, visitOpts); - let html = await page.html(); - return new JSDOM(html); - } - - async function fetchAsset(url: string): Promise { - const origin = 'http://example.com'; - let u = new URL(url, origin); - if (u.origin !== origin) { - throw new Error(`fetchAsset only supports local assets, you asked for ${url}`); - } - return readFileSync(join(app.dir, 'dist', u.pathname), 'utf-8'); - } - - return { visit, fetchAsset }; -} - -export function loadFromFixtureData(fixtureNamespace: string) { - const root = join(__dirname, '..', 'fixtures', fixtureNamespace); - const paths = globby.sync('**', { cwd: root, dot: true }); - const fixtureStructure: any = {}; - - paths.forEach(path => { - set(fixtureStructure, path.split('/'), readFileSync(join(root, path), 'utf8')); - }); - - return fixtureStructure; -} +export * from './helpers/index'; diff --git a/tests/scenarios/helpers/fastboot.ts b/tests/scenarios/helpers/fastboot.ts new file mode 100644 index 000000000..bcb777d6e --- /dev/null +++ b/tests/scenarios/helpers/fastboot.ts @@ -0,0 +1,52 @@ +import { PreparedApp } from 'scenario-tester'; +import { join } from 'path'; +import { readFileSync } from 'fs'; +import type { JSDOM } from 'jsdom'; + +export interface FastbootTestHelpers { + visit(url: string): Promise; + fetchAsset(url: string): Promise; +} + +export async function setupFastboot( + app: PreparedApp, + environment = 'development', + envVars?: Record +): Promise { + let result = await app.execute(`node node_modules/ember-cli/bin/ember build --environment=${environment}`, { + env: envVars, + }); + + if (result.exitCode !== 0) { + throw new Error(`failed to build app for fastboot: ${result.output}`); + } + + const FastBoot = require('fastboot'); + + let fastboot = new FastBoot({ + distPath: join(app.dir, 'dist'), + resilient: false, + }); + + async function visit(url: string) { + const jsdom = require('jsdom'); + const { JSDOM } = jsdom; + let visitOpts = { + request: { headers: { host: 'localhost:4200' } }, + }; + let page = await fastboot.visit(url, visitOpts); + let html = await page.html(); + return new JSDOM(html); + } + + async function fetchAsset(url: string): Promise { + const origin = 'http://example.com'; + let u = new URL(url, origin); + if (u.origin !== origin) { + throw new Error(`fetchAsset only supports local assets, you asked for ${url}`); + } + return readFileSync(join(app.dir, 'dist', u.pathname), 'utf-8'); + } + + return { visit, fetchAsset }; +} diff --git a/tests/scenarios/helpers/filesystem.ts b/tests/scenarios/helpers/filesystem.ts new file mode 100644 index 000000000..5d940ee80 --- /dev/null +++ b/tests/scenarios/helpers/filesystem.ts @@ -0,0 +1,45 @@ +import fs from 'fs/promises'; + +export 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}` + ); +} + +export 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}` + ); +} diff --git a/tests/scenarios/helpers/fixtures.ts b/tests/scenarios/helpers/fixtures.ts new file mode 100644 index 000000000..d4549d72e --- /dev/null +++ b/tests/scenarios/helpers/fixtures.ts @@ -0,0 +1,16 @@ +import { join } from 'path'; +import { readFileSync } from 'fs'; +import globby from 'globby'; +import { set } from 'lodash'; + +export function loadFromFixtureData(fixtureNamespace: string) { + const root = join(__dirname, '..', '..', 'fixtures', fixtureNamespace); + const paths = globby.sync('**', { cwd: root, dot: true }); + const fixtureStructure: any = {}; + + paths.forEach(path => { + set(fixtureStructure, path.split('/'), readFileSync(join(root, path), 'utf8')); + }); + + return fixtureStructure; +} diff --git a/tests/scenarios/helpers/index.ts b/tests/scenarios/helpers/index.ts new file mode 100644 index 000000000..e84f6c706 --- /dev/null +++ b/tests/scenarios/helpers/index.ts @@ -0,0 +1,4 @@ +export * from './fastboot'; +export * from './fixtures'; +export * from './v2-addon'; +export * from './filesystem'; diff --git a/tests/scenarios/helpers/v2-addon.ts b/tests/scenarios/helpers/v2-addon.ts new file mode 100644 index 000000000..9197658b8 --- /dev/null +++ b/tests/scenarios/helpers/v2-addon.ts @@ -0,0 +1,91 @@ +import { PreparedApp } from 'scenario-tester'; +import { spawn } from 'child_process'; + +export 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; + } +}