tests: add cli unit tests (#719)

This commit is contained in:
Vedant K 2022-07-18 19:07:06 +05:30 committed by GitHub
parent f8abc37125
commit 36d60e7552
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1496 additions and 193 deletions

16
config/vitest.ts Normal file
View File

@ -0,0 +1,16 @@
// config/vitest.ts
// The vitest configuration file.
import { env } from 'node:process';
import { defineConfig } from 'vitest/config';
// Make sure the output of the CLI is in color, so that it matches the
// snapshots.
env.FORCE_COLOR = 2;
export default defineConfig({
test: {
// Collect coverage using C8.
coverage: { enabled: true },
},
});

View File

@ -25,7 +25,9 @@
"start": "node ./build/main.js",
"compile": "tsup ./source/main.ts",
"test:tsc": "tsc --project tsconfig.json",
"test": "pnpm test:tsc",
"test:unit": "vitest run --config config/vitest.ts",
"test:watch": "vitest watch --config config/vitest.ts",
"test": "pnpm test:tsc && pnpm test:unit",
"lint:code": "eslint --max-warnings 0 source/**/*.ts",
"lint:style": "prettier --check --ignore-path .gitignore .",
"lint": "pnpm lint:code && pnpm lint:style",
@ -37,6 +39,7 @@
"ajv": "8.11.0",
"arg": "5.0.2",
"boxen": "7.0.0",
"c8": "7.11.3",
"chalk": "5.0.1",
"chalk-template": "0.4.0",
"clipboardy": "3.0.0",
@ -50,12 +53,14 @@
"@types/serve-handler": "6.1.1",
"@vercel/style-guide": "3.0.0",
"eslint": "8.19.0",
"got": "12.1.0",
"husky": "8.0.1",
"lint-staged": "13.0.3",
"prettier": "2.7.1",
"tsup": "6.1.3",
"tsx": "3.7.1",
"typescript": "4.6.4"
"typescript": "4.6.4",
"vitest": "0.18.0"
},
"tsup": {
"target": "esnext",
@ -79,7 +84,8 @@
"prettier --ignore-unknown --write"
],
"source/**/*.ts": [
"eslint --max-warnings 0 --fix"
"eslint --max-warnings 0 --fix",
"vitest related --run"
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,88 +3,76 @@
// source/main.ts
// The CLI for the `serve-handler` module.
import { cwd as getPwd, exit, env, stdout } from 'node:process';
import path from 'node:path';
import chalk from 'chalk';
import boxen from 'boxen';
import clipboard from 'clipboardy';
import checkForUpdate from 'update-check';
import manifest from '../package.json';
import { resolve } from './utilities/promise.js';
import { startServer } from './utilities/server.js';
import { registerCloseListener } from './utilities/http.js';
import { parseArguments, getHelpText } from './utilities/cli.js';
import {
parseArguments,
getHelpText,
checkForUpdates,
} from './utilities/cli.js';
import { loadConfiguration } from './utilities/config.js';
import { logger } from './utilities/logger.js';
import type { Arguments } from './types.js';
/**
* Checks for updates to this package. If an update is available, it brings it
* to the user's notice by printing a message to the console.
*
* @param debugMode - Whether or not we should print additional debug information.
* @returns
*/
const printUpdateNotification = async (debugMode?: boolean) => {
const [error, update] = await resolve(checkForUpdate(manifest));
if (error) {
const suffix = debugMode ? ':' : ' (use `--debug` to see full error).';
logger.warn(`Checking for updates failed${suffix}`);
if (debugMode) logger.error(error.message);
}
if (!update) return;
logger.log(
chalk.bgRed.white(' UPDATE '),
`The latest version of \`serve\` is ${update.latest}.`,
);
};
// Parse the options passed by the user.
let args: Arguments;
try {
args = parseArguments();
} catch (error: unknown) {
logger.error((error as Error).message);
process.exit(1);
const [parseError, args] = await resolve(parseArguments());
// Either TSC complains that `args` is undefined (which it shouldn't), or ESLint
// rightfully complains of an unnecessary condition.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (parseError || !args) {
logger.error(parseError.message);
exit(1);
}
// Check for updates to the package unless the user sets the `NO_UPDATE_CHECK`
// variable.
if (process.env.NO_UPDATE_CHECK !== '1')
await printUpdateNotification(args['--debug']);
const [updateError] = await resolve(checkForUpdates(manifest));
if (updateError) {
const suffix = args['--debug'] ? ':' : ' (use `--debug` to see full error)';
logger.warn(`Checking for updates failed${suffix}`);
if (args['--debug']) logger.error(updateError.message);
}
// If the `version` or `help` arguments are passed, print the version or the
// help text and exit.
if (args['--version']) {
logger.log(manifest.version);
process.exit(0);
exit(0);
}
if (args['--help']) {
logger.log(getHelpText());
process.exit(0);
exit(0);
}
// Default to listening on port 3000.
if (!args['--listen'])
args['--listen'] = [
[process.env.PORT ? parseInt(process.env.PORT, 10) : 3000],
];
args['--listen'] = [{ port: parseInt(env.PORT ?? '3000', 10) }];
// Ensure that the user has passed only one directory to serve.
if (args._.length > 1) {
logger.error('Please provide one path argument at maximum');
process.exit(1);
exit(1);
}
// Warn the user about using deprecated configuration files.
if (args['--config'] === 'now.json' || args['--config'] === 'package.json')
logger.warn(
'The config files `now.json` and `package.json` are deprecated. Please use `serve.json`.',
);
// Parse the configuration.
const cwd = process.cwd();
const entry = args._[0] ? path.resolve(args._[0]) : cwd;
const config = await loadConfiguration(cwd, entry, args);
const presentDirectory = getPwd();
const directoryToServe = args._[0] ? path.resolve(args._[0]) : presentDirectory;
const [configError, config] = await resolve(
loadConfiguration(presentDirectory, directoryToServe, args),
);
// Either TSC complains that `args` is undefined (which it shouldn't), or ESLint
// rightfully complains of an unnecessary condition.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (configError || !config) {
logger.error(configError.message);
exit(1);
}
// If the user wants all the URLs rewritten to `/index.html`, make it happen.
if (args['--single']) {
@ -115,8 +103,8 @@ for (const endpoint of args['--listen']) {
// If we are not in a TTY or Node is running in production mode, print
// a single line of text with the server address.
if (!process.stdout.isTTY || process.env.NODE_ENV === 'production') {
const suffix = local ? ` at ${local}.` : '.';
if (!stdout.isTTY || env.NODE_ENV === 'production') {
const suffix = local ? ` at ${local}` : '';
logger.info(`Accepting connections${suffix}`);
continue;
@ -170,6 +158,6 @@ registerCloseListener(() => {
logger.log();
logger.warn('Force-closing all open sockets...');
process.exit(0);
exit(0);
});
});

View File

@ -27,7 +27,10 @@ export declare type ListenEndpoint =
| `pipe:\\\\.\\pipe\\${Host}`;
// The parsed endpoints.
export declare type ParsedEndpoint = [Port] | [Host] | [Port, Host];
export declare interface ParsedEndpoint {
port?: Port;
host?: Host;
}
// An entry for URL rewrites.
export declare interface Rewrite {

View File

@ -1,46 +1,18 @@
// source/utilities/cli.ts
// CLI-related utility functions.
import chalk from 'chalk-template';
import { parse as parseUrl } from 'node:url';
import { env } from 'node:process';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import parseArgv from 'arg';
import { parseEndpoint } from './http.js';
import type { Arguments } from '../types.js';
// The options the CLI accepts, and how to parse them.
const options = {
'--help': Boolean,
'--version': Boolean,
'--listen': [parseEndpoint] as [typeof parseEndpoint],
'--single': Boolean,
'--debug': Boolean,
'--config': String,
'--no-clipboard': Boolean,
'--no-compression': Boolean,
'--no-etag': Boolean,
'--symlinks': Boolean,
'--cors': Boolean,
'--no-port-switching': Boolean,
'--ssl-cert': String,
'--ssl-key': String,
'--ssl-pass': String,
// A list of aliases for the above options.
'-h': '--help',
'-v': '--version',
'-l': '--listen',
'-s': '--single',
'-d': '--debug',
'-c': '--config',
'-n': '--no-clipboard',
'-u': '--no-compression',
'-S': '--symlinks',
'-C': '--cors',
// The `-p` option is deprecated and is kept only for backwards-compatibility.
'-p': '--listen',
};
import checkForUpdate from 'update-check';
import { resolve } from './promise.js';
import { logger } from './logger.js';
import type { Arguments, ParsedEndpoint } from '../types.js';
// The help text for the CLI.
const helpText = chalk`
const helpText = chalkTemplate`
{bold.cyan serve} - Static file serving and directory listing
{bold USAGE}
@ -112,6 +84,93 @@ const helpText = chalk`
{bold $} {cyan serve} -l pipe:\\\\.\\pipe\\{underline PipeName}
`;
/**
* Returns the help text.
*
* @returns The help text shown when the `--help` option is used.
*/
export const getHelpText = (): string => helpText;
/**
* Parse and return the endpoints from the given string.
*
* @param uriOrPort - The endpoint to listen on.
* @returns A list of parsed endpoints.
*/
export const parseEndpoint = (uriOrPort: string): ParsedEndpoint => {
// If the endpoint is a port number, return it as is.
if (!isNaN(Number(uriOrPort))) return { port: Number(uriOrPort) };
// Cast it as a string, since we know for sure it is not a number.
const endpoint = uriOrPort;
// We cannot use `new URL` here, otherwise it will not
// parse the host properly and it would drop support for IPv6.
const url = parseUrl(endpoint);
switch (url.protocol) {
case 'pipe:': {
const pipe = endpoint.replace(/^pipe:/, '');
if (!pipe.startsWith('\\\\.\\'))
throw new Error(`Invalid Windows named pipe endpoint: ${endpoint}`);
return { host: pipe };
}
case 'unix:':
if (!url.pathname)
throw new Error(`Invalid UNIX domain socket endpoint: ${endpoint}`);
return { host: url.pathname };
case 'tcp:':
url.port = url.port ?? '3000';
url.hostname = url.hostname ?? 'localhost';
return {
port: Number(url.port),
host: url.hostname,
};
default:
throw new Error(
`Unknown --listen endpoint scheme (protocol): ${
url.protocol ?? 'undefined'
}`,
);
}
};
// The options the CLI accepts, and how to parse them.
const options = {
'--help': Boolean,
'--version': Boolean,
'--listen': [parseEndpoint] as [typeof parseEndpoint],
'--single': Boolean,
'--debug': Boolean,
'--config': String,
'--no-clipboard': Boolean,
'--no-compression': Boolean,
'--no-etag': Boolean,
'--symlinks': Boolean,
'--cors': Boolean,
'--no-port-switching': Boolean,
'--ssl-cert': String,
'--ssl-key': String,
'--ssl-pass': String,
// A list of aliases for the above options.
'-h': '--help',
'-v': '--version',
'-l': '--listen',
'-s': '--single',
'-d': '--debug',
'-c': '--config',
'-n': '--no-clipboard',
'-u': '--no-compression',
'-S': '--symlinks',
'-C': '--cors',
// The `-p` option is deprecated and is kept only for backwards-compatibility.
'-p': '--listen',
};
/**
* Parses the program's `process.argv` and returns the options and arguments.
*
@ -120,8 +179,23 @@ const helpText = chalk`
export const parseArguments = (): Arguments => parseArgv(options);
/**
* Returns the help text.
*
* @returns The help text shown when the `--help` option is used.
* Checks for updates to this package. If an update is available, it brings it
* to the user's notice by printing a message to the console.
*/
export const getHelpText = (): string => helpText;
export const checkForUpdates = async (manifest: object): Promise<void> => {
// Do not check for updates if the `NO_UPDATE_CHECK` variable is set.
if (env.NO_UPDATE_CHECK) return;
// Check for a newer version of the package.
const [error, update] = await resolve(checkForUpdate(manifest));
// If there is an error, throw it; and if there is no update, return.
if (error) throw error;
if (!update) return;
// If a newer version is available, tell the user.
logger.log(
chalk.bgRed.white(' UPDATE '),
`The latest version of \`serve\` is ${update.latest}`,
);
};

View File

@ -10,23 +10,24 @@ import Ajv from 'ajv';
// @ts-expect-error No type definitions.
import schema from '@zeit/schemas/deployment/config-static.js';
import { resolve } from './promise.js';
import { logger } from './logger.js';
import type { ErrorObject } from 'ajv';
import type { Configuration, Options, NodeError } from '../types.js';
/**
* Parses and returns a configuration object from the designated locations.
*
* @param cwd - The current working directory.
* @param entry - The directory to serve.
* @param presentDirectory - The current working directory.
* @param directoryToServe - The directory to serve.
* @param args - The arguments passed to the CLI.
*
* @returns The parsed configuration.
*/
export const loadConfiguration = async (
cwd: string,
entry: string,
presentDirectory: string,
directoryToServe: string,
args: Partial<Options>,
): Promise<Configuration> => {
): Promise<Partial<Configuration>> => {
const files = ['serve.json', 'now.json', 'package.json'];
if (args['--config']) files.unshift(args['--config']);
@ -34,7 +35,7 @@ export const loadConfiguration = async (
for (const file of files) {
// Resolve the path to the configuration file relative to the directory
// with the content in it.
const location = resolvePath(entry, file);
const location = resolvePath(directoryToServe, file);
// Disabling the lint rule as we don't want to read all the files at once;
// if we can retrieve the configuration from the first file itself, we
@ -96,16 +97,25 @@ export const loadConfiguration = async (
// Once we have found a valid configuration, assign it and stop looking
// through more configuration files.
Object.assign(config, parsedJson);
// Warn the user about using deprecated configuration files.
if (file === 'now.json' || file === 'package.json')
logger.warn(
'The config files `now.json` and `package.json` are deprecated. Please use `serve.json`.',
);
break;
}
// Make sure the directory with the content is relative to the entry path
// Make sure the directory with the content is relative to the directoryToServe path
// provided by the user.
if (entry) {
if (directoryToServe) {
const staticDirectory = config.public;
config.public = resolveRelativePath(
cwd,
staticDirectory ? resolvePath(entry, staticDirectory) : entry,
presentDirectory,
staticDirectory
? resolvePath(directoryToServe, staticDirectory)
: directoryToServe,
);
}
@ -130,5 +140,5 @@ export const loadConfiguration = async (
config.etag = !args['--no-etag'];
config.symlinks = args['--symlinks'] || config.symlinks;
return config as Configuration;
return config;
};

View File

@ -1,62 +1,16 @@
// source/utilities/http.ts
// Helper functions for the server.
import { parse } from 'node:url';
import { networkInterfaces as getNetworkInterfaces } from 'node:os';
import type { ParsedEndpoint } from '../types.js';
const networkInterfaces = getNetworkInterfaces();
/**
* Parse and return the endpoints from the given string.
*
* @param uriOrPort - The endpoint to listen on.
* @returns A list of parsed endpoints.
*/
export const parseEndpoint = (uriOrPort: string): ParsedEndpoint => {
// If the endpoint is a port number, return it as is.
if (!isNaN(Number(uriOrPort))) return [uriOrPort];
// Cast it as a string, since we know for sure it is not a number.
const endpoint = uriOrPort;
// We cannot use `new URL` here, otherwise it will not
// parse the host properly and it would drop support for IPv6.
const url = parse(endpoint);
switch (url.protocol) {
case 'pipe:': {
const pipe = endpoint.replace(/^pipe:/, '');
if (!pipe.startsWith('\\\\.\\'))
throw new Error(`Invalid Windows named pipe endpoint: ${endpoint}`);
return [pipe];
}
case 'unix:':
if (!url.pathname)
throw new Error(`Invalid UNIX domain socket endpoint: ${endpoint}`);
return [url.pathname];
case 'tcp:':
url.port = url.port ?? '3000';
url.hostname = url.hostname ?? 'localhost';
return [parseInt(url.port, 10), url.hostname];
default:
throw new Error(
`Unknown --listen endpoint scheme (protocol): ${
url.protocol ?? 'undefined'
}`,
);
}
};
/**
* Registers a function that runs on server shutdown.
*
* @param fn - The function to run on server shutdown
*/
export const registerCloseListener = (fn: () => void) => {
export const registerCloseListener = (fn: () => void): void => {
let run = false;
const wrapper = () => {
@ -76,7 +30,7 @@ export const registerCloseListener = (fn: () => void) => {
*
* @returns The address of the host.
*/
export const getNetworkAddress = () => {
export const getNetworkAddress = (): string | undefined => {
for (const interfaceDetails of Object.values(networkInterfaces)) {
if (!interfaceDetails) continue;

View File

@ -6,11 +6,11 @@
import chalk from 'chalk';
const info = (...message: string[]) =>
console.error(chalk.magenta('INFO:', ...message));
console.error(chalk.bgMagenta.bold(' INFO '), ...message);
const warn = (...message: string[]) =>
console.error(chalk.yellow('WARNING:', ...message));
console.error(chalk.bgYellow.bold(' WARN '), ...message);
const error = (...message: string[]) =>
console.error(chalk.red('ERROR:', ...message));
console.error(chalk.bgRed.bold(' ERROR '), ...message);
const log = console.log;
export const logger = { info, warn, error, log };

View File

@ -13,16 +13,20 @@
* else console.log(data)
* ```
*
* @param promise - The promise to resolve.
* @param promiseLike - The promise to resolve.
* @returns An array containing the error as the first element, and the resolved
* data as the second element.
*/
export const resolve = <T = unknown, E = Error>(
promise: Promise<T>,
): Promise<[E, undefined] | [undefined, T]> =>
promise
.then<[undefined, T]>((data) => [undefined, data])
.catch<[E, undefined]>((error) => [error, undefined]);
export const resolve = async <T = unknown, E = Error>(
promiseLike: Promise<T> | T,
): Promise<[E, undefined] | [undefined, T]> => {
try {
const data = await promiseLike;
return [undefined, data];
} catch (error: unknown) {
return [error as E, undefined];
}
};
/**
* Promisifies the passed function.

View File

@ -122,17 +122,17 @@ export const startServer = async (
// If the endpoint is a non-zero port, make sure it is not occupied.
if (
typeof endpoint[0] === 'number' &&
!isNaN(endpoint[0]) &&
endpoint[0] !== 0
typeof endpoint.port === 'number' &&
!isNaN(endpoint.port) &&
endpoint.port !== 0
) {
const port = endpoint[0];
const port = endpoint.port;
const isClosed = await isPortReachable(port, {
host: endpoint[1] ?? 'localhost',
host: endpoint.host ?? 'localhost',
});
// If the port is already taken, then start the server on a random port
// instead.
if (isClosed) return startServer([0], config, args, port);
if (isClosed) return startServer({ port: 0 }, config, args, port);
// Otherwise continue on to starting the server.
}
@ -140,18 +140,23 @@ export const startServer = async (
// Finally, start the server.
return new Promise((resolve, _reject) => {
// If only a port is specified, listen on the given port on localhost.
if (endpoint.length === 1 && typeof endpoint[0] === 'number')
server.listen(endpoint[0], () => resolve(getServerDetails()));
if (
typeof endpoint.port !== 'undefined' &&
typeof endpoint.host === 'undefined'
)
server.listen(endpoint.port, () => resolve(getServerDetails()));
// If the path to a socket or a pipe is given, listen on it.
else if (endpoint.length === 1 && typeof endpoint[0] === 'string')
server.listen(endpoint[0], () => resolve(getServerDetails()));
else if (
typeof endpoint.port === 'undefined' &&
typeof endpoint.host !== 'undefined'
)
server.listen(endpoint.host, () => resolve(getServerDetails()));
// If a port number and hostname are given, listen on `host:port`.
else if (
endpoint.length === 2 &&
typeof endpoint[0] === 'number' &&
typeof endpoint[1] === 'string'
typeof endpoint.port !== 'undefined' &&
typeof endpoint.host !== 'undefined'
)
server.listen(endpoint[0], endpoint[1], () =>
server.listen(endpoint.port, endpoint.host, () =>
resolve(getServerDetails()),
);
});

View File

@ -0,0 +1,23 @@
<!-- index.html -->
<!-- The HTML page rendered when the user visits the root URL. -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Page description -->
<title>Serve Application</title>
<meta
name="description"
content="An example web page you can serve with `serve`."
/>
<!-- Define the character set we use, as well as the default width. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- Tell the browser which icons to show. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
Hello there!
</body>
</html>

View File

@ -0,0 +1,4 @@
{
"public": "app/",
"renderSingle": true
}

View File

@ -0,0 +1,23 @@
<!-- index.html -->
<!-- The HTML page rendered when the user visits the root URL. -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Page description -->
<title>Serve Application</title>
<meta
name="description"
content="An example web page you can serve with `serve`."
/>
<!-- Define the character set we use, as well as the default width. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- Tell the browser which icons to show. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
Hello there!
</body>
</html>

View File

@ -0,0 +1,5 @@
{
"static": {
"public": "app/"
}
}

View File

@ -0,0 +1,23 @@
<!-- index.html -->
<!-- The HTML page rendered when the user visits the root URL. -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Page description -->
<title>Serve Application</title>
<meta
name="description"
content="An example web page you can serve with `serve`."
/>
<!-- Define the character set we use, as well as the default width. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- Tell the browser which icons to show. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
Hello there!
</body>
</html>

View File

@ -0,0 +1,3 @@
{
"symlink": ["app/"]
}

View File

@ -0,0 +1,23 @@
<!-- index.html -->
<!-- The HTML page rendered when the user visits the root URL. -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Page description -->
<title>Serve Application</title>
<meta
name="description"
content="An example web page you can serve with `serve`."
/>
<!-- Define the character set we use, as well as the default width. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- Tell the browser which icons to show. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
Hello there!
</body>
</html>

View File

@ -0,0 +1,23 @@
<!-- index.html -->
<!-- The HTML page rendered when the user visits the root URL. -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Page description -->
<title>Serve Application</title>
<meta
name="description"
content="An example web page you can serve with `serve`."
/>
<!-- Define the character set we use, as well as the default width. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- Tell the browser which icons to show. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
Hello there!
</body>
</html>

View File

@ -0,0 +1,4 @@
{
"public": "app/",
"renderSingle": true
}

View File

@ -0,0 +1,23 @@
<!-- index.html -->
<!-- The HTML page rendered when the user visits the root URL. -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Page description -->
<title>Serve Application</title>
<meta
name="description"
content="An example web page you can serve with `serve`."
/>
<!-- Define the character set we use, as well as the default width. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- Tell the browser which icons to show. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
Hello there!
</body>
</html>

View File

@ -0,0 +1,4 @@
{
"public": "app/",
"renderSingle": true
}

View File

@ -0,0 +1,75 @@
// Vitest Snapshot v1
exports[`utilities/cli > render help text 1`] = `
"
serve - Static file serving and directory listing
USAGE
$ serve --help
$ serve --version
$ serve folder_name
$ serve [-l listen_uri [-l ...]] [directory]
By default, serve will listen on 0.0.0.0:3000 and serve the
current working directory on that address.
Specifying a single --listen argument will overwrite the default, not supplement it.
OPTIONS
--help Shows this help message
-v, --version Displays the current version of serve
-l, --listen listen_uri Specify a URI endpoint on which to listen (see below) -
more than one may be specified to listen in multiple places
-p Specify custom port
-d, --debug Show debugging information
-s, --single Rewrite all not-found requests to \`index.html\`
-c, --config Specify custom path to \`serve.json\`
-C, --cors Enable CORS, sets \`Access-Control-Allow-Origin\` to \`*\`
-n, --no-clipboard Do not copy the local address to the clipboard
-u, --no-compression Do not compress files
--no-etag Send \`Last-Modified\` header instead of \`ETag\`
-S, --symlinks Resolve symlinks instead of showing 404 errors
--ssl-cert Optional path to an SSL/TLS certificate to serve with HTTPS
--ssl-key Optional path to the SSL/TLS certificate's private key
--ssl-pass Optional path to the SSL/TLS certificate's passphrase
--no-port-switching Do not open a port other than the one specified when it's taken.
ENDPOINTS
Listen endpoints (specified by the --listen or -l options above) instruct serve
to listen on one or more interfaces/ports, UNIX domain sockets, or Windows named pipes.
For TCP ports on hostname \\"localhost\\":
$ serve -l 1234
For TCP (traditional host/port) endpoints:
$ serve -l tcp://hostname:1234
For UNIX domain socket endpoints:
$ serve -l unix:/path/to/socket.sock
For Windows named pipe endpoints:
$ serve -l pipe:\\\\\\\\.\\\\pipe\\\\PipeName
"
`;

View File

@ -0,0 +1,35 @@
// Vitest Snapshot v1
exports[`utilities/config > parse valid config 1`] = `
{
"etag": true,
"public": "tests/__fixtures__/config/valid/app",
"renderSingle": true,
"symlinks": undefined,
}
`;
exports[`utilities/config > parse valid config at custom location 1`] = `
{
"etag": true,
"public": "tests/__fixtures__/config/custom/app",
"renderSingle": true,
"symlinks": undefined,
}
`;
exports[`utilities/config > return default configuration when no source is found 1`] = `
{
"etag": true,
"public": "tests/__fixtures__/config/non-existent",
"symlinks": undefined,
}
`;
exports[`utilities/config > warn when configuration comes from a deprecated source 1`] = `
{
"etag": true,
"public": "tests/__fixtures__/config/deprecated/app",
"symlinks": undefined,
}
`;

103
tests/cli.test.ts Normal file
View File

@ -0,0 +1,103 @@
// tests/cli.test.ts
// Tests for the CLI part of the project.
import { env } from 'node:process';
import { afterEach, describe, test, expect, vi } from 'vitest';
import manifest from '../package.json';
import {
getHelpText,
parseEndpoint,
checkForUpdates,
} from '../source/utilities/cli.js';
import { logger } from '../source/utilities/logger.js';
import { ParsedEndpoint } from '../source/types.js';
afterEach(() => {
vi.restoreAllMocks();
});
// A list of cases used to test the `parseEndpoint` function. The first element
// is the name of the case, followed by the input to pass to the function,
// followed by the expected output.
type EndpointTestCase = [string, string, ParsedEndpoint];
const validEndpoints = [
['http port', '4242', { port: 4242 }],
['tcp url', 'tcp://localhost:4242', { port: 4242, host: 'localhost' }],
['unix socket', 'unix:///dev/sock1', { host: '/dev/sock1' }],
['pipe', 'pipe:\\\\.\\pipe\\localhost', { host: '\\\\.\\pipe\\localhost' }],
] as EndpointTestCase[];
// Another list of cases used to test the `parseEndpoint` function. The function
// should throw an error when parsing any of these cases, as they are invalid
// endpoints.
type InvalidEndpointTestCase = [string, string, RegExp];
const invalidEndpoints = [
['protocol', 'ws://localhost', /unknown.*endpoint.*scheme.*/i],
['unix socket', 'unix://', /invalid.*unix.*socket.*/i],
['windows pipe', 'pipe:\\localhost', /invalid.*pipe.*/i],
] as InvalidEndpointTestCase[];
describe('utilities/cli', () => {
// Make sure the help message remains the same. If we are changing the help
// message, then make sure to run `vitest` with the `--update-snapshot` flag.
test('render help text', () => expect(getHelpText()).toMatchSnapshot());
// Make sure the `parseEndpoint` function parses valid endpoints correctly.
test.each(validEndpoints)(
'parse %s as endpoint',
(_name, endpoint, parsedEndpoint) =>
expect(parseEndpoint(endpoint)).toEqual(parsedEndpoint),
);
// Make sure `parseEndpoint` throws errors on invalid endpoints.
test.each(invalidEndpoints)(
'parse %s as endpoint',
(_name, endpoint, error) =>
expect(() => parseEndpoint(endpoint)).toThrow(error),
);
// Make sure the update message is shown when the current version is not
// the latest version.
test('print update message when newer version exists', async () => {
const consoleSpy = vi.spyOn(logger, 'log');
await checkForUpdates({
...manifest,
version: '0.0.0',
});
expect(consoleSpy).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenLastCalledWith(
expect.stringContaining('UPDATE'),
expect.stringContaining('latest'),
);
});
// Make sure the update message is not shown when the latest version is
// running.
test('do not print update message when on latest version', async () => {
const consoleSpy = vi.spyOn(logger, 'log');
await checkForUpdates({
...manifest,
version: '99.99.99',
});
expect(consoleSpy).not.toHaveBeenCalled();
});
// Make sure an update check does not occur when the NO_UPDATE_CHECK env var
// is set.
test('do not check for updates when NO_UPDATE_CHECK is set', async () => {
const consoleSpy = vi.spyOn(logger, 'log');
env.NO_UPDATE_CHECK = 'true';
await checkForUpdates({
...manifest,
version: '0.0.0',
});
env.NO_UPDATE_CHECK = undefined;
expect(consoleSpy).not.toHaveBeenCalled();
});
});

68
tests/config.test.ts Normal file
View File

@ -0,0 +1,68 @@
// tests/config.test.ts
// Tests for the configuration loader.
import { afterEach, describe, test, expect, vi } from 'vitest';
import { loadConfiguration } from '../source/utilities/config.js';
import { logger } from '../source/utilities/logger.js';
import { Options } from '../source/types.js';
// The path to the fixtures for this test file.
const fixtures = 'tests/__fixtures__/config/';
// A helper function to load the configuration for a certain fixture.
const loadConfig = (
name: 'valid' | 'invalid' | 'non-existent' | 'deprecated',
args?: Partial<Options> = {},
) => loadConfiguration(process.cwd(), `${fixtures}/${name}`, args);
afterEach(() => {
vi.restoreAllMocks();
});
describe('utilities/config', () => {
// Make sure the configuration is parsed correctly when it is in the
// `serve.json` file.
test('parse valid config', async () => {
const configuration = await loadConfig('valid');
expect(configuration).toMatchSnapshot();
});
// Make sure the configuration is parsed correctly when it is a location
// specified by the `--config` option.
test('parse valid config at custom location', async () => {
const configuration = await loadConfig('custom', {
'--config': 'config.json',
});
expect(configuration).toMatchSnapshot();
});
// When the configuration in the file is invalid, the function will throw an
// error.
test('throw error if config is invalid', async () => {
loadConfig('invalid').catch((error: Error) => {
expect(error.message).toMatch(/invalid/);
});
});
// When no configuration file exists, the configuration should be populated
// with the `etag` and `symlink` options set to their default values, and
// the `public` option set to the path of the directory.
test('return default configuration when no source is found', async () => {
const configuration = await loadConfig('non-existent');
expect(configuration).toMatchSnapshot();
});
// When the configuration source is deprecated, i.e., the configuration lives
// in `now.json` or `package.json`, a warning should be printed.
test('warn when configuration comes from a deprecated source', async () => {
const consoleSpy = vi.spyOn(logger, 'warn');
const configuration = await loadConfig('deprecated');
expect(configuration).toMatchSnapshot();
expect(consoleSpy).toHaveBeenCalledOnce();
expect(consoleSpy).toHaveBeenLastCalledWith(
expect.stringContaining('deprecated'),
);
});
});

57
tests/server.test.ts Normal file
View File

@ -0,0 +1,57 @@
// tests/config.test.ts
// Tests for the configuration loader.
import { afterEach, describe, test, expect, vi } from 'vitest';
import { extend as createFetch } from 'got';
import { loadConfiguration } from '../source/utilities/config.js';
import { startServer } from '../source/utilities/server.js';
// The path to the fixtures for this test file.
const fixture = 'tests/__fixtures__/server/';
// The configuration from the fixture.
const config = await loadConfiguration(process.cwd(), fixture, {});
// A `fetch` instance to make requests to the server.
const fetch = createFetch({ throwHttpErrors: false });
afterEach(() => {
vi.restoreAllMocks();
});
describe('utilities/server', () => {
// Make sure the server starts on the specified port.
test('start server on specified port', async () => {
const address = await startServer({ port: 3001 }, config, {});
expect(address.local).toBe('http://localhost:3001');
expect(address.network).toMatch(/^http:\/\/.*:3001$/);
expect(address.previous).toBeUndefined();
const response = await fetch(address.local!);
expect(response.ok);
});
// Make sure the server starts on the specified port and host.
test('start server on specified port and host', async () => {
const address = await startServer({ port: 3002, host: '::1' }, config, {});
expect(address.local).toBe('http://[::1]:3002');
expect(address.network).toMatch(/^http:\/\/.*:3002$/);
expect(address.previous).toBeUndefined();
const response = await fetch(address.local!);
expect(response.ok);
});
// Make sure the server starts on the specified port and host.
test('start server on different port if port is already occupied', async () => {
const address = await startServer({ port: 3002, host: '::1' }, config, {});
expect(address.local).not.toBe('http://[::1]:3002');
expect(address.network).not.toMatch(/^http:\/\/.*:3002$/);
expect(address.previous).toBe(3002);
const response = await fetch(address.local!);
expect(response.ok);
});
});