diff --git a/packages/astro/e2e/test-utils.js b/packages/astro/e2e/test-utils.js index 158a61d5d..460b3185f 100644 --- a/packages/astro/e2e/test-utils.js +++ b/packages/astro/e2e/test-utils.js @@ -1,3 +1,202 @@ -import { createFixtureLoader } from '../test-utils.js'; +import { execa } from 'execa'; +import { polyfill } from '@astrojs/webapi'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { resolveConfig, loadConfig } from '../dist/core/config.js'; +import dev from '../dist/core/dev/index.js'; +import build from '../dist/core/build/index.js'; +import preview from '../dist/core/preview/index.js'; +import { nodeLogDestination } from '../dist/core/logger/node.js'; +import os from 'os'; +import stripAnsi from 'strip-ansi'; -export const loadFixture = createFixtureLoader('e2e'); +// polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16 +polyfill(globalThis, { + exclude: 'window document', +}); + +/** + * @typedef {import('node-fetch').Response} Response + * @typedef {import('../src/core/dev/index').DevServer} DevServer + * @typedef {import('../src/@types/astro').AstroConfig} AstroConfig + * @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer + * @typedef {import('../src/core/app/index').App} App + * + * + * @typedef {Object} Fixture + * @property {typeof build} build + * @property {(url: string, opts: any) => Promise} fetch + * @property {(path: string) => Promise} readFile + * @property {(path: string) => Promise} readdir + * @property {() => Promise} startDevServer + * @property {() => Promise} preview + * @property {() => Promise} clean + * @property {() => Promise} loadTestAdapterApp + */ + +/** + * Load Astro fixture + * @param {AstroConfig} inlineConfig Astro config partial (note: must specify `root`) + * @returns {Promise} The fixture. Has the following properties: + * .config - Returns the final config. Will be automatically passed to the methods below: + * + * Build + * .build() - Async. Builds into current folder (will erase previous build) + * .readFile(path) - Async. Read a file from the build. + * + * Dev + * .startDevServer() - Async. Starts a dev server at an available port. Be sure to call devServer.stop() before test exit. + * .fetch(url) - Async. Returns a URL from the prevew server (must have called .preview() before) + * + * Preview + * .preview() - Async. Starts a preview server. Note this can’t be running in same fixture as .dev() as they share ports. Also, you must call `server.close()` before test exit + * + * Clean-up + * .clean() - Async. Removes the project’s dist folder. + */ +export async function loadFixture(inlineConfig) { + if (!inlineConfig || !inlineConfig.root) + throw new Error("Must provide { root: './fixtures/...' }"); + + // load config + let cwd = inlineConfig.root; + delete inlineConfig.root; + if (typeof cwd === 'string') { + try { + cwd = new URL(cwd.replace(/\/?$/, '/')); + } catch (err1) { + cwd = new URL(cwd.replace(/\/?$/, '/'), import.meta.url); + } + } + // Load the config. + let config = await loadConfig({ cwd: fileURLToPath(cwd) }); + config = merge(config, { ...inlineConfig, root: cwd }); + + // Note: the inline config doesn't run through config validation where these normalizations usually occur + if (typeof inlineConfig.site === 'string') { + config.site = new URL(inlineConfig.site); + } + if (inlineConfig.base && !inlineConfig.base.endsWith('/')) { + config.base = inlineConfig.base + '/'; + } + + /** @type {import('../src/core/logger/core').LogOptions} */ + const logging = { + dest: nodeLogDestination, + level: 'error', + }; + + /** @type {import('@astrojs/telemetry').AstroTelemetry} */ + const telemetry = { + record() { + return Promise.resolve(); + }, + }; + + return { + build: (opts = {}) => build(config, { mode: 'development', logging, telemetry, ...opts }), + startDevServer: async (opts = {}) => { + const devResult = await dev(config, { logging, telemetry, ...opts }); + config.server.port = devResult.address.port; // update port + return devResult; + }, + config, + fetch: (url, init) => + fetch(`http://${'127.0.0.1'}:${config.server.port}${url.replace(/^\/?/, '/')}`, init), + preview: async (opts = {}) => { + const previewServer = await preview(config, { logging, telemetry, ...opts }); + return previewServer; + }, + readFile: (filePath) => + fs.promises.readFile(new URL(filePath.replace(/^\//, ''), config.outDir), 'utf8'), + readdir: (fp) => fs.promises.readdir(new URL(fp.replace(/^\//, ''), config.outDir)), + clean: () => fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true }), + loadTestAdapterApp: async () => { + const url = new URL('./server/entry.mjs', config.outDir); + const { createApp } = await import(url); + return createApp(); + }, + }; +} + +/** + * Basic object merge utility. Returns new copy of merged Object. + * @param {Object} a + * @param {Object} b + * @returns {Object} + */ +function merge(a, b) { + const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]); + const c = {}; + for (const k of allKeys) { + const needsObjectMerge = + typeof a[k] === 'object' && + typeof b[k] === 'object' && + (Object.keys(a[k]).length || Object.keys(b[k]).length) && + !Array.isArray(a[k]) && + !Array.isArray(b[k]); + if (needsObjectMerge) { + c[k] = merge(a[k] || {}, b[k] || {}); + continue; + } + c[k] = a[k]; + if (b[k] !== undefined) c[k] = b[k]; + } + return c; +} + +const cliPath = fileURLToPath(new URL('../astro.js', import.meta.url)); + +/** Returns a process running the Astro CLI. */ +export function cli(/** @type {string[]} */ ...args) { + const spawned = execa('node', [cliPath, ...args]); + + spawned.stdout.setEncoding('utf8'); + + return spawned; +} + +export async function parseCliDevStart(proc) { + let stdout = ''; + let stderr = ''; + + for await (const chunk of proc.stdout) { + stdout += chunk; + if (chunk.includes('Local')) break; + } + if (!stdout) { + for await (const chunk of proc.stderr) { + stderr += chunk; + break; + } + } + + proc.kill(); + stdout = stripAnsi(stdout); + stderr = stripAnsi(stderr); + + if (stderr) { + throw new Error(stderr); + } + + const messages = stdout + .split('\n') + .filter((ln) => !!ln.trim()) + .map((ln) => ln.replace(/[🚀┃]/g, '').replace(/\s+/g, ' ').trim()); + + return { messages }; +} + +export async function cliServerLogSetup(flags = [], cmd = 'dev') { + const proc = cli(cmd, ...flags); + + const { messages } = await parseCliDevStart(proc); + + const local = messages.find((msg) => msg.includes('Local'))?.replace(/Local\s*/g, ''); + const network = messages.find((msg) => msg.includes('Network'))?.replace(/Network\s*/g, ''); + + return { local, network }; +} + +export const isWindows = os.platform() === 'win32'; diff --git a/packages/astro/test-utils.js b/packages/astro/test-utils.js deleted file mode 100644 index 026892559..000000000 --- a/packages/astro/test-utils.js +++ /dev/null @@ -1,204 +0,0 @@ -import { execa } from 'execa'; -import { polyfill } from '@astrojs/webapi'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { resolveConfig, loadConfig } from './dist/core/config.js'; -import dev from './dist/core/dev/index.js'; -import build from './dist/core/build/index.js'; -import preview from './dist/core/preview/index.js'; -import { nodeLogDestination } from './dist/core/logger/node.js'; -import os from 'os'; -import stripAnsi from 'strip-ansi'; - -// polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16 -polyfill(globalThis, { - exclude: 'window document', -}); - -/** - * @typedef {import('node-fetch').Response} Response - * @typedef {import('../src/core/dev/index').DevServer} DevServer - * @typedef {import('../src/@types/astro').AstroConfig} AstroConfig - * @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer - * @typedef {import('../src/core/app/index').App} App - * - * - * @typedef {Object} Fixture - * @property {typeof build} build - * @property {(url: string, opts: any) => Promise} fetch - * @property {(path: string) => Promise} readFile - * @property {(path: string) => Promise} readdir - * @property {() => Promise} startDevServer - * @property {() => Promise} preview - * @property {() => Promise} clean - * @property {() => Promise} loadTestAdapterApp - */ - -export function createFixtureLoader(root) { - /** - * Load Astro fixture - * @param {AstroConfig} inlineConfig Astro config partial (note: must specify `root`) - * @returns {Promise} The fixture. Has the following properties: - * .config - Returns the final config. Will be automatically passed to the methods below: - * - * Build - * .build() - Async. Builds into current folder (will erase previous build) - * .readFile(path) - Async. Read a file from the build. - * - * Dev - * .startDevServer() - Async. Starts a dev server at an available port. Be sure to call devServer.stop() before test exit. - * .fetch(url) - Async. Returns a URL from the prevew server (must have called .preview() before) - * - * Preview - * .preview() - Async. Starts a preview server. Note this can’t be running in same fixture as .dev() as they share ports. Also, you must call `server.close()` before test exit - * - * Clean-up - * .clean() - Async. Removes the project’s dist folder. - */ - return async function loadFixture(inlineConfig) { - if (!inlineConfig || !inlineConfig.root) - throw new Error("Must provide { root: './fixtures/...' }"); - - // load config - let cwd = path.join(root, inlineConfig.root); - delete inlineConfig.root; - if (typeof cwd === 'string') { - try { - cwd = new URL(cwd.replace(/\/?$/, '/')); - } catch (err1) { - cwd = new URL(cwd.replace(/\/?$/, '/'), import.meta.url); - } - } - // Load the config. - let config = await loadConfig({ cwd: fileURLToPath(cwd) }); - config = merge(config, { ...inlineConfig, root: cwd }); - - // Note: the inline config doesn't run through config validation where these normalizations usually occur - if (typeof inlineConfig.site === 'string') { - config.site = new URL(inlineConfig.site); - } - if (inlineConfig.base && !inlineConfig.base.endsWith('/')) { - config.base = inlineConfig.base + '/'; - } - - /** @type {import('../src/core/logger/core').LogOptions} */ - const logging = { - dest: nodeLogDestination, - level: 'error', - }; - - /** @type {import('@astrojs/telemetry').AstroTelemetry} */ - const telemetry = { - record() { - return Promise.resolve(); - }, - }; - - return { - build: (opts = {}) => build(config, { mode: 'development', logging, telemetry, ...opts }), - startDevServer: async (opts = {}) => { - const devResult = await dev(config, { logging, telemetry, ...opts }); - config.server.port = devResult.address.port; // update port - return devResult; - }, - config, - fetch: (url, init) => - fetch(`http://${'127.0.0.1'}:${config.server.port}${url.replace(/^\/?/, '/')}`, init), - preview: async (opts = {}) => { - const previewServer = await preview(config, { logging, telemetry, ...opts }); - return previewServer; - }, - readFile: (filePath) => - fs.promises.readFile(new URL(filePath.replace(/^\//, ''), config.outDir), 'utf8'), - readdir: (fp) => fs.promises.readdir(new URL(fp.replace(/^\//, ''), config.outDir)), - clean: () => fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true }), - loadTestAdapterApp: async () => { - const url = new URL('./server/entry.mjs', config.outDir); - const { createApp } = await import(url); - return createApp(); - }, - }; - } -} - -/** - * Basic object merge utility. Returns new copy of merged Object. - * @param {Object} a - * @param {Object} b - * @returns {Object} - */ -function merge(a, b) { - const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]); - const c = {}; - for (const k of allKeys) { - const needsObjectMerge = - typeof a[k] === 'object' && - typeof b[k] === 'object' && - (Object.keys(a[k]).length || Object.keys(b[k]).length) && - !Array.isArray(a[k]) && - !Array.isArray(b[k]); - if (needsObjectMerge) { - c[k] = merge(a[k] || {}, b[k] || {}); - continue; - } - c[k] = a[k]; - if (b[k] !== undefined) c[k] = b[k]; - } - return c; -} - -const cliPath = fileURLToPath(new URL('../astro.js', import.meta.url)); - -/** Returns a process running the Astro CLI. */ -export function cli(/** @type {string[]} */ ...args) { - const spawned = execa('node', [cliPath, ...args]); - - spawned.stdout.setEncoding('utf8'); - - return spawned; -} - -export async function parseCliDevStart(proc) { - let stdout = ''; - let stderr = ''; - - for await (const chunk of proc.stdout) { - stdout += chunk; - if (chunk.includes('Local')) break; - } - if (!stdout) { - for await (const chunk of proc.stderr) { - stderr += chunk; - break; - } - } - - proc.kill(); - stdout = stripAnsi(stdout); - stderr = stripAnsi(stderr); - - if (stderr) { - throw new Error(stderr); - } - - const messages = stdout - .split('\n') - .filter((ln) => !!ln.trim()) - .map((ln) => ln.replace(/[🚀┃]/g, '').replace(/\s+/g, ' ').trim()); - - return { messages }; -} - -export async function cliServerLogSetup(flags = [], cmd = 'dev') { - const proc = cli(cmd, ...flags); - - const { messages } = await parseCliDevStart(proc); - - const local = messages.find((msg) => msg.includes('Local'))?.replace(/Local\s*/g, ''); - const network = messages.find((msg) => msg.includes('Network'))?.replace(/Network\s*/g, ''); - - return { local, network }; -} - -export const isWindows = os.platform() === 'win32'; diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index c3c5146a8..267e4039f 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -1,8 +1,12 @@ import { execa } from 'execa'; import { polyfill } from '@astrojs/webapi'; +import fs from 'fs'; import { fileURLToPath } from 'url'; +import { resolveConfig, loadConfig } from '../dist/core/config.js'; +import dev from '../dist/core/dev/index.js'; import build from '../dist/core/build/index.js'; -import { createFixtureLoader } from '../test-utils.js'; +import preview from '../dist/core/preview/index.js'; +import { nodeLogDestination } from '../dist/core/logger/node.js'; import os from 'os'; import stripAnsi from 'strip-ansi'; @@ -11,7 +15,135 @@ polyfill(globalThis, { exclude: 'window document', }); -export const loadFixture = createFixtureLoader('test'); +/** + * @typedef {import('node-fetch').Response} Response + * @typedef {import('../src/core/dev/index').DevServer} DevServer + * @typedef {import('../src/@types/astro').AstroConfig} AstroConfig + * @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer + * @typedef {import('../src/core/app/index').App} App + * + * + * @typedef {Object} Fixture + * @property {typeof build} build + * @property {(url: string, opts: any) => Promise} fetch + * @property {(path: string) => Promise} readFile + * @property {(path: string) => Promise} readdir + * @property {() => Promise} startDevServer + * @property {() => Promise} preview + * @property {() => Promise} clean + * @property {() => Promise} loadTestAdapterApp + */ + +/** + * Load Astro fixture + * @param {AstroConfig} inlineConfig Astro config partial (note: must specify `root`) + * @returns {Promise} The fixture. Has the following properties: + * .config - Returns the final config. Will be automatically passed to the methods below: + * + * Build + * .build() - Async. Builds into current folder (will erase previous build) + * .readFile(path) - Async. Read a file from the build. + * + * Dev + * .startDevServer() - Async. Starts a dev server at an available port. Be sure to call devServer.stop() before test exit. + * .fetch(url) - Async. Returns a URL from the prevew server (must have called .preview() before) + * + * Preview + * .preview() - Async. Starts a preview server. Note this can’t be running in same fixture as .dev() as they share ports. Also, you must call `server.close()` before test exit + * + * Clean-up + * .clean() - Async. Removes the project’s dist folder. + */ +export async function loadFixture(inlineConfig) { + if (!inlineConfig || !inlineConfig.root) + throw new Error("Must provide { root: './fixtures/...' }"); + + // load config + let cwd = inlineConfig.root; + delete inlineConfig.root; + if (typeof cwd === 'string') { + try { + cwd = new URL(cwd.replace(/\/?$/, '/')); + } catch (err1) { + cwd = new URL(cwd.replace(/\/?$/, '/'), import.meta.url); + } + } + // Load the config. + let config = await loadConfig({ cwd: fileURLToPath(cwd) }); + config = merge(config, { ...inlineConfig, root: cwd }); + + // Note: the inline config doesn't run through config validation where these normalizations usually occur + if (typeof inlineConfig.site === 'string') { + config.site = new URL(inlineConfig.site); + } + if (inlineConfig.base && !inlineConfig.base.endsWith('/')) { + config.base = inlineConfig.base + '/'; + } + + /** @type {import('../src/core/logger/core').LogOptions} */ + const logging = { + dest: nodeLogDestination, + level: 'error', + }; + + /** @type {import('@astrojs/telemetry').AstroTelemetry} */ + const telemetry = { + record() { + return Promise.resolve(); + }, + }; + + return { + build: (opts = {}) => build(config, { mode: 'development', logging, telemetry, ...opts }), + startDevServer: async (opts = {}) => { + const devResult = await dev(config, { logging, telemetry, ...opts }); + config.server.port = devResult.address.port; // update port + return devResult; + }, + config, + fetch: (url, init) => + fetch(`http://${'127.0.0.1'}:${config.server.port}${url.replace(/^\/?/, '/')}`, init), + preview: async (opts = {}) => { + const previewServer = await preview(config, { logging, telemetry, ...opts }); + return previewServer; + }, + readFile: (filePath) => + fs.promises.readFile(new URL(filePath.replace(/^\//, ''), config.outDir), 'utf8'), + readdir: (fp) => fs.promises.readdir(new URL(fp.replace(/^\//, ''), config.outDir)), + clean: () => fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true }), + loadTestAdapterApp: async () => { + const url = new URL('./server/entry.mjs', config.outDir); + const { createApp } = await import(url); + return createApp(); + }, + }; +} + +/** + * Basic object merge utility. Returns new copy of merged Object. + * @param {Object} a + * @param {Object} b + * @returns {Object} + */ +function merge(a, b) { + const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]); + const c = {}; + for (const k of allKeys) { + const needsObjectMerge = + typeof a[k] === 'object' && + typeof b[k] === 'object' && + (Object.keys(a[k]).length || Object.keys(b[k]).length) && + !Array.isArray(a[k]) && + !Array.isArray(b[k]); + if (needsObjectMerge) { + c[k] = merge(a[k] || {}, b[k] || {}); + continue; + } + c[k] = a[k]; + if (b[k] !== undefined) c[k] = b[k]; + } + return c; +} const cliPath = fileURLToPath(new URL('../astro.js', import.meta.url)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c45bb4d0..462527438 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -659,7 +659,7 @@ importers: chai-as-promised: 7.1.1_chai@4.3.6 mocha: 9.2.2 - packages/astro/e2e/fixtures/tailwindcss: + packages/astro/e2e/fixtures/basic: specifiers: '@astrojs/tailwind': workspace:* astro: workspace:*