From e6102fc9d79b2ad8d77bd7f4f14c9925b69eb3d1 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Mon, 9 Sep 2024 23:20:36 -0300 Subject: [PATCH] feat: New test package (#158) Co-authored-by: Florian Lefebvre <69633530+florian-lefebvre@users.noreply.github.com> Co-authored-by: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Signed-off-by: Luiz Ferraz --- .changeset/strong-trees-bow.md | 5 + .github/labeler.yml | 3 + .gitignore | 2 + docs/astro.config.ts | 10 + docs/src/content/docs/astro-tests.mdx | 369 ++++++++++++++++++ packages/astro-tests/README.md | 15 + packages/astro-tests/package.json | 41 ++ packages/astro-tests/src/astroFixture.ts | 352 +++++++++++++++++ packages/astro-tests/src/index.ts | 6 + packages/astro-tests/src/noNodeModule.ts | 31 ++ packages/astro-tests/src/testAdapter.ts | 151 +++++++ packages/astro-tests/src/utils.ts | 39 ++ packages/astro-tests/tsconfig.json | 4 + packages/astro-tests/tsup.config.ts | 35 ++ packages/astro-when/package.json | 9 +- packages/astro-when/src/index.ts | 3 + .../fixture/server-output/astro.config.ts | 7 + .../tests/fixture/server-output/package.json | 9 + .../server-output/src/pages/on-demand.ts | 8 + .../server-output/src/pages/prerendered.ts | 8 + .../fixture/static-output/astro.config.ts | 6 + .../tests/fixture/static-output/package.json | 9 + .../static-output/src/pages/index.astro | 7 + .../astro-when/tests/server-output.test.ts | 60 +++ .../astro-when/tests/static-output.test.ts | 39 ++ packages/astro-when/tests/vitest.setup.ts | 6 + packages/astro-when/vitest.config.mjs | 12 + pnpm-lock.yaml | 85 ++++ pnpm-workspace.yaml | 2 + turbo.json | 2 +- 30 files changed, 1332 insertions(+), 3 deletions(-) create mode 100644 .changeset/strong-trees-bow.md create mode 100644 docs/src/content/docs/astro-tests.mdx create mode 100644 packages/astro-tests/README.md create mode 100644 packages/astro-tests/package.json create mode 100644 packages/astro-tests/src/astroFixture.ts create mode 100644 packages/astro-tests/src/index.ts create mode 100644 packages/astro-tests/src/noNodeModule.ts create mode 100644 packages/astro-tests/src/testAdapter.ts create mode 100644 packages/astro-tests/src/utils.ts create mode 100644 packages/astro-tests/tsconfig.json create mode 100644 packages/astro-tests/tsup.config.ts create mode 100644 packages/astro-when/tests/fixture/server-output/astro.config.ts create mode 100644 packages/astro-when/tests/fixture/server-output/package.json create mode 100644 packages/astro-when/tests/fixture/server-output/src/pages/on-demand.ts create mode 100644 packages/astro-when/tests/fixture/server-output/src/pages/prerendered.ts create mode 100644 packages/astro-when/tests/fixture/static-output/astro.config.ts create mode 100644 packages/astro-when/tests/fixture/static-output/package.json create mode 100644 packages/astro-when/tests/fixture/static-output/src/pages/index.astro create mode 100644 packages/astro-when/tests/server-output.test.ts create mode 100644 packages/astro-when/tests/static-output.test.ts create mode 100644 packages/astro-when/tests/vitest.setup.ts create mode 100644 packages/astro-when/vitest.config.mjs diff --git a/.changeset/strong-trees-bow.md b/.changeset/strong-trees-bow.md new file mode 100644 index 00000000..b38798df --- /dev/null +++ b/.changeset/strong-trees-bow.md @@ -0,0 +1,5 @@ +--- +'@inox-tools/astro-tests': minor +--- + +Initial release diff --git a/.github/labeler.yml b/.github/labeler.yml index 5834a56b..ccc4b886 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -40,5 +40,8 @@ pkg/request-state: pkg/content-utils: - 'packages/content-utils/**' +pkg/astro-tests: +- 'packages/astro-tests/**' + pkg/velox-luna: - 'packages/velox-luna/**' diff --git a/.gitignore b/.gitignore index ae9c89c6..ae4d3254 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,8 @@ node_modules/ # Build outputs dist/ +.astro/ +**/src/env.d.ts build/ .inox-tools/ *.js.map diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 52bea43f..f458e9bb 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -75,6 +75,16 @@ export default defineConfig({ }, ], }, + { + label: 'Tools for Authors', + collapsed: false, + items: [ + { + label: 'Astro Tests', + link: '/astro-tests', + }, + ], + }, { label: 'Modular Station', collapsed: false, diff --git a/docs/src/content/docs/astro-tests.mdx b/docs/src/content/docs/astro-tests.mdx new file mode 100644 index 00000000..3c263eaa --- /dev/null +++ b/docs/src/content/docs/astro-tests.mdx @@ -0,0 +1,369 @@ +--- +title: Astro Test Utils +packageName: '@inox-tools/astro-tests' +description: Utilities for testing your own Astro integrations and libraries based on Astro's own testing tools. +--- + +Astro's current ecosystem lacks solutions for testing integrations, adapters, and components without either copying internal entire parts of the Astro code or bypassing package encapsulation to import internal modules from Astro. This tool aims to fill that gap by providing high-level wrapper over the known practices required to test Astro libraries. + +It offers utilities for setting up environments, running builds, and inspecting renders, ensuring compatibility and performance without the need to tamper with internal Astro logic. + +## Compatibility + +Relying on internal details of Astro can lead to your code breaking at any time. Those internal pieces can move, break or be removed from Astro even on patch versions since they are not part of the public API. + +Although this library also utilizes some parts of the Astro code that are meant to be internal, projects using this library for their tests will not break in case those parts are changed. This is ensured by how it built and published, although the source code breaks encapsulation, the code in the published library relies only on Astro's public API. + +You can check on your `node_modules` directory to see it 😉 + +:::warn[Possibility of an official Astro package] +There is ongoing conversation about having this in the Astro core repo to be published under `@astrojs/`. + +This package is published as a milestone in that effort. It is also a necessity to test all other packages that are part of Inox Tools until this proposal lands on Astro core. If/when that happens, this package will be published as a thin wrapper around the official test package and deprecated. + +If the conversations ends in the decision of not having a test package provided from Astro itself, then this package will continue to work for that purpose. +::: + +## How to install + +import { PackageManagers } from 'starlight-package-managers'; + + + +## Fixtures + +### `config` + +

**Type:** `AstroConfig`

+ +The final config passed to [Astro's programatic CLI entrypoints](https://docs.astro.build/en/reference/cli-reference/#advanced-apis-experimental). This configuration can be overridden for each method call. +It will automatically be passed to the following methods: + +- [`startDevServer()`](#startdevserver) +- [`build()`](#build) +- [`preview()`](#preview) +- [`sync()`](#sync) + +### `startDevServer` + +{( + +

+ Type: + + (inlineConfig: + + + + AstroInlineConfig + + + + ) => Promise<DevServer> + +

+)} + +Starts a development server on an available port. + +This server cannot run simultaneously with `.preview()` for the same fixture, as they share ports. +Ensure `devServer.stop()` is called before the test exits. + +Equivalent to running [`astro dev`](https://docs.astro.build/en/reference/cli-reference/#astro-dev). + +### `build` + +{( + +

+ Type: + + (inlineConfig: + + + + AstroInlineConfig + + + + ) => Promise<void> + +

+)} + +Builds into current folder (will erase previous build). + +Equivalent to running [`astro build`](https://docs.astro.build/en/reference/cli-reference/#astro-build). + +### `preview` + +{( + +

+ Type: + + (inlineConfig: + + + + AstroInlineConfig + + + + ) => Promise<PreviewServer> + +

+)} + +Starts a preview server. + +This server cannot run simultaneously with `.dev()` on the same fixture, as they share ports. +Ensure `server.stop()` is called before the test exits. + +Equivalent to running [`astro preview`](https://docs.astro.build/en/reference/cli-reference/#astro-preview). + +### `sync` + +{( + +

+ Type: + + (inlineConfig: + + + + AstroInlineConfig + + + + ) => Promise<void> + +

+)} + +Synchronizes the Astro project and configuration with the generated code, populating the `src/env.d.ts` file and the `.astro` directory. + +Equivalent to running [`astro sync`](https://docs.astro.build/en/reference/cli-reference/#astro-sync). + +### `clean` + +

+ **Type:** `() => Promise` +

+ +Deletes the generated files from the fixture directory. Specifically, it deletes: + +- The output directory (`outDir` config) +- The cache directory (`cacheDir` config) +- The `.astro` directory generated in the project +- the `.astro` directory generated in the `node_modules` + +### `resolveUrl` + +

**Type:** `(url: string) => string`

+ +Resolves a relative URL to the full url of the running server. + +This can only be called after either [`.startDevServer()`](#startdevserver) or [`.preview()`](#preview) is called. + +### `fetch` + +{( + +

+ Type: + + (url: string, opts?:{' '} + + + + RequestInit + + + + ) => Promise< + + + + Response + + + + > + +

+)} + +Send a request to the given URL. If the URL is relative, it will be resolved relative to the root of the server (without a base path). + +This can only be called after either [`.startDevServer()`](#startdevserver) or [`.preview()`](#preview) is called. + +### `pathExists` + +

**Type:** `(path: string) => boolean`

+ +Checks whether the given path exists on the build output (`outDir` config). + +### `readFile` + +

+ **Type:** `(path: string) => Promise` +

+ +Read a file from the build (relative to `outDir` config). + +### `editFile` + +

+ **Type:** `(path: string, updater: string | ((content: string) => string)) => Promise<() => void>` +

+ +Edit a file in the fixture directory. + +The first parameter is a path relative to the root of the fixture. + +The second parameter can be the new content of the file or a function that takes the current content and returns the new content. + +This function returns a Promise that resolves to another function. This resolved function can be called to revert the changes. + +All changes made with `editFile` are automatically reverted before the [process exits](https://nodejs.org/api/process.html#event-exit). + +### `resetAllFiles` + +

**Type:** `() => void`

+ +Reset all changes made with [`.editFile()`](#editfile) + +### `readdir` + +

+ **Type:** `(path: string) => Promise` +

+ +Read a directory from the build output (relative to `outDir` config). + +This is a convenience wrapper for [readdir from Node's FS Promise API](https://nodejs.org/api/fs.html#fspromisesreaddirpath-options). + +### `glob` + +

+ **Type:** `(pattern: string) => Promise` +

+ +Find entries in the build output matching the glob pattern. + +The glob syntax used is from [`fast-glob`](https://www.npmjs.com/package/fast-glob#pattern-syntax). + +### `loadNodeAdapterHandler` + +{( + +

+ Type: + + () => Promise<(req:{' '} + + + + http.IncomingMessage + + + + , res:{' '} + + + + http.ServerResponse + + + + ) => void> + +

+)} + +Load the handler for an app built using the [Node Adapter](https://docs.astro.build/en/guides/integrations-guide/node/). + +The handler is the same as a listener for the [`request` event](https://nodejs.org/api/http.html#event-request) from Node's native HTTP module. + +### `loadTestAdapterApp` + +

+ **Type:** `() => Promise` +

+ +#### `TestApp` + +```ts +type TestApp = { + render: (req: Request) => Promise; + toInternalApp: () => App; +}; +``` + +A minimal proxy for the underlying Astro app, provided by the test adapter. + +##### `render` + +Renders a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) from the given [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request). + +##### `toInternalApp` + +Returns the underlying [Astro App](https://github.com/withastro/astro/blob/ca54e3f819fad009ac3c3c8b57a26014a2652a73/packages/astro/src/core/app/index.ts#L77-L518). + +:::danger +This class is internal, undocumented and highly unstable. Use it at your own risk. +::: + +## Test Adapter + +An [Astro Adapter](https://docs.astro.build/en/guides/server-side-rendering/) that exposes the rendering process to be called directly. + +It also collects information about the build passed to the adapter to be inspected. + +None of the options are required. + +### `env` + +

+ **Type:** `Record` +

+ +Server-side environment variables to be used by [`astro:env`](https://docs.astro.build/en/reference/configuration-reference/#experimentalenv). + +### `setRoutes` + +{( + +

+ Type: + + (routes:{' '} + + + + RouteData + + + + []) => Promise<void> + +

+)} + +A callback function that will receive the final value of the project routes. + +## Utilities + +### No Node checker + +A Vite plugin that ensures no module in the final bundle depends on built-in Node modules. + +If any reference to a Node module is found in the generated bundle, the build will fail. + +The checked modules are those from the built-in module list provided as [part of `node:modules`](https://nodejs.org/api/module.html#modulebuiltinmodules), both with an without the `node:` prefix, as well as the [prefix-only modules](https://nodejs.org/api/modules.html#built-in-modules-with-mandatory-node-prefix). + +## License + +Astro Test Utils is available under the MIT license. diff --git a/packages/astro-tests/README.md b/packages/astro-tests/README.md new file mode 100644 index 00000000..dd9611ed --- /dev/null +++ b/packages/astro-tests/README.md @@ -0,0 +1,15 @@ +

+ InoxTools +

+ +# Astro Test Tools + +Test tooling and utilities based on Astro's own internal testing tools. + +See [docs](https://inox-tools.fryuni.dev/astro-tests). + +## Install + +```js +npm install @inox-tools/astro-tests +``` diff --git a/packages/astro-tests/package.json b/packages/astro-tests/package.json new file mode 100644 index 00000000..6e60531f --- /dev/null +++ b/packages/astro-tests/package.json @@ -0,0 +1,41 @@ +{ + "name": "@inox-tools/astro-tests", + "version": "0.0.0", + "license": "MIT", + "author": "Luiz Ferraz ", + "type": "module", + "exports": { + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + }, + "files": [ + "README.md", + "dist", + "src" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "echo 'This is the test utility, there are no tests for it'" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "dependencies": { + "@astrojs/internal-helpers": "catalog:", + "@astrojs/markdown-remark": "^5.2.0", + "@inox-tools/utils": "workspace:^", + "astro": "catalog:", + "shiki": "^1.14.1", + "fast-glob": "^3.3.2", + "strip-ansi": "^7.1.0", + "undici": "^6.19.8", + "zod": "^3" + } +} diff --git a/packages/astro-tests/src/astroFixture.ts b/packages/astro-tests/src/astroFixture.ts new file mode 100644 index 00000000..a0708d23 --- /dev/null +++ b/packages/astro-tests/src/astroFixture.ts @@ -0,0 +1,352 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import fastGlob from 'fast-glob'; +import { Agent, request } from 'undici'; +import { build, dev, preview, sync } from 'astro'; +import type { AstroConfig, AstroInlineConfig } from 'astro'; +import { mergeConfig } from '../node_modules/astro/dist/core/config/merge.js'; +import { validateConfig } from '../node_modules/astro/dist/core/config/validate.js'; +import { getViteConfig } from 'astro/config'; +import { callsites } from './utils.js'; +import type { App } from 'astro/app'; + +// Disable telemetry when running tests +process.env.ASTRO_TELEMETRY_DISABLED = 'true'; + +type InlineConfig = Omit & { root: string | URL }; +export type NodeRequest = import('node:http').IncomingMessage; +export type NodeResponse = import('node:http').ServerResponse; +export type DevServer = Awaited>; +export type PreviewServer = Awaited>; + +type TestApp = { + render: (req: Request) => Promise; + toInternalApp: () => App; +}; + +type Fixture = { + /** + * Returns the final config. + * Will be automatically passed to the methods below: + * - .startDevServer() + * - .build() + * - .preview() + * - .sync() + */ + config: AstroConfig; + /** + * Starts a dev server at an available port. + * + * This server can't be running at the same time for thein same fixture as .preview() since they share ports. + * Be sure to call devServer.stop() before test exit. + * + * Equivalent to running `astro dev`. + */ + startDevServer: typeof dev; + /** + * Builds into current folder (will erase previous build). + * + * Equivalent to running `astro build`. + */ + build: typeof build; + /** + * Starts a preview server. + * + * This server can't be running at the same time for thein same fixture as .dev() since they share ports. + * Be sure to call server.stop() before test exit. + * + * Equivalent to running `astro preview`. + */ + preview: typeof preview; + /** + * Synchronizes the Astro project and configuration with the generated code, populating the `src/env.d.ts` file and the `.astro` directory. + * + * Equivalent to running `astro sync`. + */ + sync: typeof sync; + + /** + * Removes generated directories from the fixture directory. + */ + clean: () => Promise; + /** + * Resolves a relative URL to the full url of the running server. + * + * This can only be called after either .startDevServer() or .preview() is called. + */ + resolveUrl: (url: string) => string; + /** + * Send a request to the given URL. If the URL is relative, it will be resolved relative to the root of the server (without a base path). + * + * This can only be called after either .startDevServer() or .preview() is called. + */ + fetch: (url: string, opts?: Parameters[1]) => Promise; + + /** + * Checks whether the given path exists on the build output. + */ + pathExists: (path: string) => boolean; + /** + * Read a file from the build. + */ + readFile: (path: string) => Promise; + /** + * Edit a file in the fixture. + * + * The second parameter can be the new content of the file + * or a function that takes the current content and returns the new content. + * + * Returns a function that can be called to revert the edit. + */ + editFile: (path: string, updater: string | ((content: string) => string)) => Promise<() => void>; + /** + * Reset all changes made with .editFile() + */ + resetAllFiles: () => void; + /** + * Read a directory from the build output. + */ + readdir: (path: string) => Promise; + + /** + * Find entries in the build output matching the glob pattern. + */ + glob: (pattern: string) => Promise; + /** + * Load an app built using the Test Adapter. + */ + loadTestAdapterApp: () => Promise; + /** + * Load the handler for an app built using the Node Adapter. + */ + loadNodeAdapterHandler: () => Promise<(req: NodeRequest, res: NodeResponse) => void>; +}; + +/** + * Loads an Astro fixture project. + * + * @example Using on a test suite: + * ```js + * let fixture = await loadFixture({ + * root: './fixtures/astro-check-watch/', + * }); + * ``` + */ +export async function loadFixture(inlineConfig: InlineConfig): Promise { + if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }"); + + // Silent by default during tests to not pollute the console output + inlineConfig.logLevel = 'silent'; + inlineConfig.vite ??= {}; + inlineConfig.vite.logLevel = 'silent'; + // Prevent hanging when testing the dev server on some scenarios + inlineConfig.vite.optimizeDeps ??= {}; + inlineConfig.vite.optimizeDeps.noDiscovery = true; + + let root = inlineConfig.root; + if (typeof root !== 'string') { + // Handle URL, should already be absolute so just convert to path + root = fileURLToPath(root); + } else if (root.startsWith('file://')) { + // Handle "file:///C:/Users/fred", convert to "C:/Users/fred" + root = fileURLToPath(new URL(root)); + } else if (!path.isAbsolute(root)) { + const [caller] = callsites().slice(1); + let callerUrl = caller.getScriptNameOrSourceURL() || undefined; + if (callerUrl?.startsWith('file:') === false) { + callerUrl = pathToFileURL(callerUrl).toString(); + } + // Handle "./fixtures/...", convert to absolute path relative to the caller of this function. + root = fileURLToPath(new URL(root, callerUrl)); + } + inlineConfig.root = root; + const config = await validateConfig(inlineConfig, root, 'dev'); + const viteConfig = await getViteConfig( + {}, + { ...inlineConfig, root } + )({ + command: 'serve', + mode: 'dev', + }); + const viteServerOptions = viteConfig.server!; + const protocol = viteServerOptions.https ? 'https' : 'http'; + + const resolveUrl = (url: string) => + `${protocol}://${viteServerOptions.host! || 'localhost'}:${viteServerOptions.port}${url.replace( + /^\/?/, + '/' + )}`; + + // A map of files that have been edited. + let fileEdits = new Map(); + + const resetAllFiles = () => { + for (const [, reset] of fileEdits) { + reset(); + } + fileEdits.clear(); + }; + + let fixtureId = new Date().valueOf(); + let devServer: DevServer; + + const onNextChange = () => + devServer + ? new Promise((resolve) => devServer.watcher.once('change', resolve)) + : Promise.reject(new Error('No dev server running')); + + // Also do it on process exit, just in case. + process.on('exit', resetAllFiles); + + return { + config, + startDevServer: async (extraInlineConfig = {}) => { + process.env.NODE_ENV = 'development'; + devServer = await dev( + mergeConfig(inlineConfig, { + ...extraInlineConfig, + force: true, + }) + ); + viteServerOptions.host = parseAddressToHost(devServer.address.address)!; // update host + viteServerOptions.port = devServer.address.port; // update port + return devServer; + }, + build: async (extraInlineConfig = {}) => { + process.env.NODE_ENV = 'production'; + return build(mergeConfig(inlineConfig, extraInlineConfig)); + }, + preview: async (extraInlineConfig = {}) => { + process.env.NODE_ENV = 'production'; + const previewServer = await preview(mergeConfig(inlineConfig, extraInlineConfig)); + viteServerOptions.host = parseAddressToHost(previewServer.host)!; // update host + viteServerOptions.port = previewServer.port; // update port + return previewServer; + }, + sync, + + clean: async () => { + await fs.promises.rm(config.outDir, { + maxRetries: 10, + recursive: true, + force: true, + }); + + await fs.promises.rm(new URL('./node_modules/.astro', config.root), { + maxRetries: 10, + recursive: true, + force: true, + }); + + await fs.promises.rm(config.cacheDir, { + maxRetries: 10, + recursive: true, + force: true, + }); + + await fs.promises.rm(new URL('./.astro', config.root), { + maxRetries: 10, + recursive: true, + force: true, + }); + }, + resolveUrl, + fetch: async (url, init) => { + if (config.vite?.server?.https) { + init = { + // Use a custom fetch dispatcher. This is an undici option that allows + // us to customize the fetch behavior. We use it here to allow h2. + dispatcher: new Agent({ + connect: { + // We disable cert validation because we're using self-signed certs + rejectUnauthorized: false, + }, + // Enable HTTP/2 support + allowH2: true, + }), + ...init, + }; + } + const resolvedUrl = resolveUrl(url); + try { + return await request(resolvedUrl, init).then(async (res) => { + const blob = await res.body.blob(); + const headers = new Headers(); + for (const [key, value] of Object.entries(res.headers)) { + if (Array.isArray(value)) { + value.forEach((v) => headers.append(key, v)); + } else if (value) { + headers.append(key, value); + } + } + + return new Response(await blob.arrayBuffer(), { + headers, + status: res.statusCode, + }); + }); + } catch (err: any) { + // node fetch throws a vague error when it fails, so we log the url here to easily debug it + if (err.message?.includes('fetch failed')) { + console.error(`[astro test] failed to fetch ${resolvedUrl}`); + console.error(err); + } + throw err; + } + }, + + pathExists: (p) => fs.existsSync(new URL(p.replace(/^\//, ''), config.outDir)), + readFile: (filePath) => + fs.promises.readFile(new URL(filePath.replace(/^\//, ''), config.outDir), 'utf8'), + editFile: async (filePath, newContentsOrCallback) => { + const fileUrl = new URL(filePath.replace(/^\//, ''), config.root); + const contents = await fs.promises.readFile(fileUrl, 'utf-8'); + const reset = () => { + fs.writeFileSync(fileUrl, contents); + }; + // Only save this reset if not already in the map, in case multiple edits happen + // to the same file. + if (!fileEdits.has(fileUrl.toString())) { + fileEdits.set(fileUrl.toString(), reset); + } + const newContents = + typeof newContentsOrCallback === 'function' + ? newContentsOrCallback(contents) + : newContentsOrCallback; + const nextChange = devServer ? onNextChange() : Promise.resolve(); + await fs.promises.writeFile(fileUrl, newContents); + await nextChange; + return reset; + }, + readdir: (fp) => fs.promises.readdir(new URL(fp.replace(/^\//, ''), config.outDir)), + + glob: (p) => + fastGlob(p, { + cwd: fileURLToPath(config.outDir), + }), + loadTestAdapterApp: async () => { + const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir); + const { createApp, manifest } = await import(url.toString()); + const app = createApp(); + app.manifest = manifest; + return { + render: (req) => app.render(req), + toInternalApp: () => app, + }; + }, + loadNodeAdapterHandler: async () => { + const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir); + const { handler } = await import(url.toString()); + return handler; + }, + resetAllFiles, + }; +} + +function parseAddressToHost(address?: string) { + if (address?.startsWith('::')) { + return `[${address}]`; + } + return address; +} diff --git a/packages/astro-tests/src/index.ts b/packages/astro-tests/src/index.ts new file mode 100644 index 00000000..146a6e38 --- /dev/null +++ b/packages/astro-tests/src/index.ts @@ -0,0 +1,6 @@ +import testAdapter from './testAdapter.js'; + +export { testAdapter }; + +export * from './astroFixture.js'; +export * from './noNodeModule.js'; diff --git a/packages/astro-tests/src/noNodeModule.ts b/packages/astro-tests/src/noNodeModule.ts new file mode 100644 index 00000000..27ab766f --- /dev/null +++ b/packages/astro-tests/src/noNodeModule.ts @@ -0,0 +1,31 @@ +/** + * Copied from: https://github.com/withastro/astro/blob/d2574ad8932039f3eea3bd6d9368bec377a0c334/packages/astro/test/test-plugins.js + * Modified to check all declared Node built-in modules instead of a small subset. + */ + +import type { Plugin } from 'vite'; +import { builtinModules } from 'node:module'; + +const nodeModules = [ + ...builtinModules, + ...builtinModules.map((modName) => `node:${modName}`), + 'node:test', + 'node:test/reporters', + 'node:sea', +]; + +export function preventNodeBuiltinDependencyPlugin(): Plugin { + // Verifies that the final bundle does not have a hard dependency on Node builtins. + // This is to verify it will run on Cloudflare and Deno + return { + name: 'verify-no-node-stuff', + generateBundle() { + nodeModules.forEach((name) => { + const mod = this.getModuleInfo(name); + if (mod) { + throw new Error(`Node builtins snuck in: ${name}`); + } + }); + }, + }; +} diff --git a/packages/astro-tests/src/testAdapter.ts b/packages/astro-tests/src/testAdapter.ts new file mode 100644 index 00000000..040ce628 --- /dev/null +++ b/packages/astro-tests/src/testAdapter.ts @@ -0,0 +1,151 @@ +/** + * Copied from: https://github.com/withastro/astro/blob/d2574ad8932039f3eea3bd6d9368bec377a0c334/packages/astro/test/test-adapter.js + * Modified to use TypeScript and to comply with new types and requirements. + */ + +import type { AstroIntegration, HookParameters } from 'astro'; + +type EntryPoints = HookParameters<'astro:build:ssr'>['entryPoints']; +type MiddlewareEntryPoint = HookParameters<'astro:build:ssr'>['middlewareEntryPoint']; +type Routes = HookParameters<'astro:build:done'>['routes']; + +export type Options = { + /** + * Environment variables available for `astro:env` as server-side variables and secrets. + */ + env?: Record; + /** + * Whether to expose `Astro.clientAddress`. + * + * @default true + */ + provideAddress?: boolean; + + /** + * Callback to collect the build entrypoints. + * + * The collected value is a map from `RouteData` describing a route + * to the URL pointing to the file on disk that can be imported to + * render that route. + */ + setEntryPoints?: (entryPoints: EntryPoints) => void; + /** + * Callback to collect the middleware entrypoint. + * + * The collected value is the URL pointing to the file on disk. + * It will be `undefined` if no middleware is used. + */ + setMiddlewareEntryPoint?: (middlewareEntryPoint: MiddlewareEntryPoint) => void; + /** + * Callback to collect the final state of the routes. + */ + setRoutes?: (routes: Routes) => void; +}; + +export default function (options: Options = {}): AstroIntegration { + const { + env, + provideAddress = true, + setEntryPoints, + setMiddlewareEntryPoint, + setRoutes, + } = options; + + return { + name: 'test-ssr-adapter', + hooks: { + 'astro:config:setup': ({ updateConfig }) => { + updateConfig({ + vite: { + plugins: [ + { + name: 'test-ssr-adapter', + resolveId(id) { + if (id === '@my-ssr') { + return id; + } + }, + load(id) { + if (id === '@my-ssr') { + return ` + import { App } from 'astro/app'; + import fs from 'fs'; + + ${env != null + ? ` + const $$env = ${JSON.stringify(env)}; + await import('astro/env/setup') + .then(mod => mod.setGetEnv((key) => $$env[key])) + .catch(() => {});` + : '' + } + + class MyApp extends App { + #manifest = null; + constructor(manifest, streaming) { + super(manifest, streaming); + this.#manifest = manifest; + } + + async render(request, { routeData, clientAddress, locals, addCookieHeader } = {}) { + const url = new URL(request.url); + if(this.#manifest.assets.has(url.pathname)) { + const filePath = new URL('../../client/' + this.removeBase(url.pathname), import.meta.url); + const data = await fs.promises.readFile(filePath); + return new Response(data); + } + + ${provideAddress ? `request[Symbol.for('astro.clientAddress')] = clientAddress ?? '0.0.0.0';` : ''} + return super.render(request, { routeData, locals, addCookieHeader }); + } + } + + export function createExports(manifest) { + return { + manifest, + createApp: (streaming) => new MyApp(manifest, streaming) + }; + } + `; + } + }, + }, + ], + }, + }); + }, + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: 'my-ssr-adapter', + serverEntrypoint: '@my-ssr', + exports: ['manifest', 'createApp'], + supportedAstroFeatures: { + serverOutput: 'stable', + envGetSecret: 'stable', + staticOutput: 'stable', + hybridOutput: 'stable', + i18nDomains: 'stable', + assets: { + supportKind: 'stable', + isSharpCompatible: true, + isSquooshCompatible: true, + }, + }, + }); + }, + 'astro:build:ssr': ({ entryPoints, middlewareEntryPoint }) => { + if (setEntryPoints) { + setEntryPoints(entryPoints); + } + if (setMiddlewareEntryPoint) { + setMiddlewareEntryPoint(middlewareEntryPoint); + } + }, + 'astro:build:done': ({ routes }) => { + if (setRoutes) { + setRoutes(routes); + } + }, + }, + }; +} diff --git a/packages/astro-tests/src/utils.ts b/packages/astro-tests/src/utils.ts new file mode 100644 index 00000000..4cf27014 --- /dev/null +++ b/packages/astro-tests/src/utils.ts @@ -0,0 +1,39 @@ +import { slash } from '@astrojs/internal-helpers/path'; +import { fileURLToPath } from 'node:url'; +import * as os from 'node:os'; + +/** + * Convert file URL to ID for viteServer.moduleGraph.idToModuleMap.get(:viteID) + * Format: + * Linux/Mac: /Users/astro/code/my-project/src/pages/index.astro + * Windows: C:/Users/astro/code/my-project/src/pages/index.astro + */ +export function viteID(filePath: URL): string { + return slash(fileURLToPath(filePath) + filePath.search); +} + +export const isLinux = os.platform() === 'linux'; +export const isMacOS = os.platform() === 'darwin'; +export const isWindows = os.platform() === 'win32'; + +export function fixLineEndings(str: string) { + return str.replace(/\r\n/g, '\n'); +} + +/** + * Like the lib `callsites` but with the proper Node types + * instead of old compatibility types. + */ +export function callsites(): NodeJS.CallSite[] { + const oldPrepare = Error.prepareStackTrace; + try { + Error.prepareStackTrace = (_, stackTrace) => stackTrace; + // TS expects it be a string, but now it is the internal objects. + const stack = new Error('nothing').stack as unknown as NodeJS.CallSite[]; + + // Remove the frame of `callsites` itself. + return stack.slice(1); + } finally { + Error.prepareStackTrace = oldPrepare; + } +} diff --git a/packages/astro-tests/tsconfig.json b/packages/astro-tests/tsconfig.json new file mode 100644 index 00000000..bfc6df51 --- /dev/null +++ b/packages/astro-tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json" +} diff --git a/packages/astro-tests/tsup.config.ts b/packages/astro-tests/tsup.config.ts new file mode 100644 index 00000000..d8e4aff0 --- /dev/null +++ b/packages/astro-tests/tsup.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'tsup'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')); +const dependencies = [ + ...Object.keys(packageJson.dependencies || {}), + ...Object.keys(packageJson.peerDependencies || {}), +].map((name) => new RegExp(`^${name}/?$`)); +const devDependencies = [...Object.keys(packageJson.devDependencies || {})] + .filter((name) => !['vite'].includes(name)) + .map((name) => new RegExp(`^${name}/?$`)); + +export default defineConfig({ + entry: ['src/**/*.ts'], + format: ['esm'], + target: 'node18', + bundle: true, + dts: true, + sourcemap: true, + clean: true, + splitting: true, + minify: false, + external: [...dependencies, 'vite', './virtual.d.ts'], + noExternal: [ + ...devDependencies, + // To inline the internal pieces of Astro that are needed for testing + // but not exposed on the public API + /^..\/node_modules\/astro\/dist\/core/, + ], + treeshake: 'smallest', + tsconfig: 'tsconfig.json', + esbuildOptions: (options) => { + options.chunkNames = 'chunks/[name]-[hash]'; + }, +}); diff --git a/packages/astro-when/package.json b/packages/astro-when/package.json index 4cfa18a0..2d39bcb5 100644 --- a/packages/astro-when/package.json +++ b/packages/astro-when/package.json @@ -27,19 +27,24 @@ "build": "tsup", "dev": "tsup --watch", "prepublish": "pnpm run build", - "test": "echo 'No tests'" + "test": "vitest run", + "test:dev": "vitest" }, "dependencies": { "astro-integration-kit": "catalog:", "debug": "catalog:" }, "devDependencies": { + "@inox-tools/astro-tests": "workspace:", "@types/debug": "catalog:", "@types/node": "catalog:", + "@vitest/ui": "catalog:", "astro": "catalog:", + "jest-extended": "catalog:", "tsup": "catalog:", "typescript": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vitest": "catalog:" }, "peerDependencies": { "astro": "catalog:lax" diff --git a/packages/astro-when/src/index.ts b/packages/astro-when/src/index.ts index 6da39597..cfeb3645 100644 --- a/packages/astro-when/src/index.ts +++ b/packages/astro-when/src/index.ts @@ -78,6 +78,9 @@ export default defineIntegration({ }); } }, + 'astro:build:done': () => { + delete (globalThis as any)[key]; + }, }, }), }); diff --git a/packages/astro-when/tests/fixture/server-output/astro.config.ts b/packages/astro-when/tests/fixture/server-output/astro.config.ts new file mode 100644 index 00000000..5339ca4e --- /dev/null +++ b/packages/astro-when/tests/fixture/server-output/astro.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import astroWhen from '@inox-tools/astro-when'; + +export default defineConfig({ + output: 'server', + integrations: [astroWhen()], +}); diff --git a/packages/astro-when/tests/fixture/server-output/package.json b/packages/astro-when/tests/fixture/server-output/package.json new file mode 100644 index 00000000..b7228a11 --- /dev/null +++ b/packages/astro-when/tests/fixture/server-output/package.json @@ -0,0 +1,9 @@ +{ + "name": "@astro-when/server-output", + "private": true, + "type": "module", + "dependencies": { + "@inox-tools/astro-when": "workspace:", + "astro": "catalog:" + } +} diff --git a/packages/astro-when/tests/fixture/server-output/src/pages/on-demand.ts b/packages/astro-when/tests/fixture/server-output/src/pages/on-demand.ts new file mode 100644 index 00000000..10562581 --- /dev/null +++ b/packages/astro-when/tests/fixture/server-output/src/pages/on-demand.ts @@ -0,0 +1,8 @@ +import { whenAmI } from '@it-astro:when'; +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = () => { + return new Response(whenAmI); +}; diff --git a/packages/astro-when/tests/fixture/server-output/src/pages/prerendered.ts b/packages/astro-when/tests/fixture/server-output/src/pages/prerendered.ts new file mode 100644 index 00000000..96ffb290 --- /dev/null +++ b/packages/astro-when/tests/fixture/server-output/src/pages/prerendered.ts @@ -0,0 +1,8 @@ +import { whenAmI } from '@it-astro:when'; +import type { APIRoute } from 'astro'; + +export const prerender = true; + +export const GET: APIRoute = () => { + return new Response(whenAmI); +}; diff --git a/packages/astro-when/tests/fixture/static-output/astro.config.ts b/packages/astro-when/tests/fixture/static-output/astro.config.ts new file mode 100644 index 00000000..c122c7e4 --- /dev/null +++ b/packages/astro-when/tests/fixture/static-output/astro.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'astro/config'; +import astroWhen from '@inox-tools/astro-when'; + +export default defineConfig({ + integrations: [astroWhen()], +}); diff --git a/packages/astro-when/tests/fixture/static-output/package.json b/packages/astro-when/tests/fixture/static-output/package.json new file mode 100644 index 00000000..55cbdefe --- /dev/null +++ b/packages/astro-when/tests/fixture/static-output/package.json @@ -0,0 +1,9 @@ +{ + "name": "@astro-when/static-output", + "private": true, + "type": "module", + "dependencies": { + "@inox-tools/astro-when": "workspace:", + "astro": "catalog:" + } +} diff --git a/packages/astro-when/tests/fixture/static-output/src/pages/index.astro b/packages/astro-when/tests/fixture/static-output/src/pages/index.astro new file mode 100644 index 00000000..8e74475c --- /dev/null +++ b/packages/astro-when/tests/fixture/static-output/src/pages/index.astro @@ -0,0 +1,7 @@ +--- +import { whenAmI } from '@it-astro:when'; + +export const partial = true; +--- + +{whenAmI} diff --git a/packages/astro-when/tests/server-output.test.ts b/packages/astro-when/tests/server-output.test.ts new file mode 100644 index 00000000..fdba0c85 --- /dev/null +++ b/packages/astro-when/tests/server-output.test.ts @@ -0,0 +1,60 @@ +import { loadFixture, type DevServer } from '@inox-tools/astro-tests/astroFixture'; +import testAdapter from '@inox-tools/astro-tests/testAdapter'; +import type { App } from 'astro/app'; +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +const fixture = await loadFixture({ + root: './fixture/server-output', + adapter: testAdapter(), +}); + +describe('Astro when on a static output project', () => { + describe('dev server', () => { + let devServer: DevServer; + + beforeAll(async () => { + await fixture.startDevServer({}); + }); + + afterAll(async () => { + devServer?.stop(); + }); + + test('identifies the dev server for prerender routes', async () => { + const res = await fixture.fetch('/prerendered'); + const content = await res.text(); + + expect(content).toEqual('devServer'); + }); + + test('identifies the dev server for on-demand routes', async () => { + const res = await fixture.fetch('/on-demand'); + const content = await res.text(); + + expect(content).toEqual('devServer'); + }); + }); + + describe('build output', () => { + let app: App; + + beforeAll(async () => { + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + test('identifies the prerender stage', async () => { + const res = await app.render(new Request('http://example.com/prerendered')); + const content = await res.text(); + + expect(content).toEqual('prerender'); + }); + + test('identifies the server stage', async () => { + const res = await app.render(new Request('http://example.com/on-demand')); + const content = await res.text(); + + expect(content).toEqual('server'); + }); + }); +}); diff --git a/packages/astro-when/tests/static-output.test.ts b/packages/astro-when/tests/static-output.test.ts new file mode 100644 index 00000000..53ad4a2c --- /dev/null +++ b/packages/astro-when/tests/static-output.test.ts @@ -0,0 +1,39 @@ +import { loadFixture, type DevServer } from '@inox-tools/astro-tests/astroFixture'; +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +const fixture = await loadFixture({ + root: './fixture/static-output', +}); + +describe('Astro when on a static output project', () => { + describe('dev server', () => { + let devServer: DevServer; + + beforeAll(async () => { + await fixture.startDevServer({}); + }); + + afterAll(async () => { + devServer?.stop(); + }); + + test('identifies the dev server', async () => { + const res = await fixture.fetch('/'); + const content = await res.text(); + + expect(content).toEqual('devServer'); + }); + }); + + describe('build time', () => { + beforeAll(async () => { + await fixture.build({}); + }); + + test('identifies the static build stage', async () => { + const content = await fixture.readFile('index.html'); + + expect(content).toEqual('staticBuild'); + }); + }); +}); diff --git a/packages/astro-when/tests/vitest.setup.ts b/packages/astro-when/tests/vitest.setup.ts new file mode 100644 index 00000000..0aebe29b --- /dev/null +++ b/packages/astro-when/tests/vitest.setup.ts @@ -0,0 +1,6 @@ +import * as matchers from 'jest-extended'; +import { expect } from 'vitest'; + +process.setSourceMapsEnabled(true); + +expect.extend(matchers); diff --git a/packages/astro-when/vitest.config.mjs b/packages/astro-when/vitest.config.mjs new file mode 100644 index 00000000..1c3af7ca --- /dev/null +++ b/packages/astro-when/vitest.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +process.env.NODE_OPTIONS ??= ''; +process.env.NODE_OPTIONS ??= '--enable-source-maps'; +process.setSourceMapsEnabled(true); + +export default defineConfig({ + test: { + setupFiles: ['./tests/vitest.setup.ts'], + maxConcurrency: 1, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0973709b..bcecd8a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@astrojs/compiler': specifier: ^2.10.3 version: 2.10.3 + '@astrojs/internal-helpers': + specifier: ^0.4.1 + version: 0.4.1 '@astrojs/sitemap': specifier: ^3.1.6 version: 3.1.6 @@ -453,6 +456,52 @@ importers: specifier: 'catalog:' version: 2.0.5(@types/node@22.5.4)(@vitest/ui@2.0.5) + packages/astro-tests: + dependencies: + '@astrojs/internal-helpers': + specifier: 'catalog:' + version: 0.4.1 + '@astrojs/markdown-remark': + specifier: ^5.2.0 + version: 5.2.0 + '@inox-tools/utils': + specifier: workspace:^ + version: link:../utils + astro: + specifier: 'catalog:' + version: 4.15.3(@types/node@22.5.4)(rollup@4.21.2)(typescript@5.5.4) + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 + shiki: + specifier: ^1.14.1 + version: 1.16.2 + strip-ansi: + specifier: ^7.1.0 + version: 7.1.0 + undici: + specifier: ^6.19.8 + version: 6.19.8 + zod: + specifier: ^3 + version: 3.23.8 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.5.4 + tsup: + specifier: 'catalog:' + version: 8.2.4(jiti@1.21.6)(postcss@8.4.45)(typescript@5.5.4)(yaml@2.5.1) + typescript: + specifier: 'catalog:' + version: 5.5.4 + vite: + specifier: 'catalog:' + version: 5.4.3(@types/node@22.5.4) + vitest: + specifier: 'catalog:' + version: 2.0.5(@types/node@22.5.4)(@vitest/ui@2.0.5) + packages/astro-when: dependencies: astro-integration-kit: @@ -462,15 +511,24 @@ importers: specifier: 'catalog:' version: 4.3.7 devDependencies: + '@inox-tools/astro-tests': + specifier: 'workspace:' + version: link:../astro-tests '@types/debug': specifier: 'catalog:' version: 4.1.12 '@types/node': specifier: 'catalog:' version: 22.5.4 + '@vitest/ui': + specifier: 'catalog:' + version: 2.0.5(vitest@2.0.5) astro: specifier: 'catalog:' version: 4.15.3(@types/node@22.5.4)(rollup@4.21.2)(typescript@5.5.4) + jest-extended: + specifier: 'catalog:' + version: 4.0.2 tsup: specifier: 'catalog:' version: 8.2.4(jiti@1.21.6)(postcss@8.4.45)(typescript@5.5.4)(yaml@2.5.1) @@ -480,6 +538,27 @@ importers: vite: specifier: 'catalog:' version: 5.4.3(@types/node@22.5.4) + vitest: + specifier: 'catalog:' + version: 2.0.5(@types/node@22.5.4)(@vitest/ui@2.0.5) + + packages/astro-when/tests/fixture/server-output: + dependencies: + '@inox-tools/astro-when': + specifier: 'workspace:' + version: link:../../.. + astro: + specifier: 'catalog:' + version: 4.15.3(@types/node@22.5.4)(rollup@4.21.2)(typescript@5.5.4) + + packages/astro-when/tests/fixture/static-output: + dependencies: + '@inox-tools/astro-when': + specifier: 'workspace:' + version: link:../../.. + astro: + specifier: 'catalog:' + version: 4.15.3(@types/node@22.5.4)(rollup@4.21.2)(typescript@5.5.4) packages/content-utils: dependencies: @@ -4614,6 +4693,10 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici@6.19.8: + resolution: {integrity: sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==} + engines: {node: '>=18.17'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -9597,6 +9680,8 @@ snapshots: undici-types@6.19.8: {} + undici@6.19.8: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 54b5ebd2..7f58c2f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ catalog: '@astrojs/compiler': ^2.10.3 '@astrojs/sitemap': ^3.1.6 '@astrojs/starlight': ^0.26.4 + '@astrojs/internal-helpers': ^0.4.1 '@types/content-type': ^1.1.8 '@types/debug': ^4.1.12 '@types/node': ^22.5.4 @@ -38,3 +39,4 @@ packages: - docs - packages/* - examples/* + - packages/*/tests/fixture/* diff --git a/turbo.json b/turbo.json index 7f34ea1f..771f8621 100644 --- a/turbo.json +++ b/turbo.json @@ -16,7 +16,7 @@ "persistent": true }, "test": { - "dependsOn": ["^build"], + "dependsOn": ["build"], "cache": false, "outputLogs": "new-only" }