tests: add cli unit tests (#719)
This commit is contained in:
parent
f8abc37125
commit
36d60e7552
|
@ -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 },
|
||||
},
|
||||
});
|
12
package.json
12
package.json
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
773
pnpm-lock.yaml
773
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}`,
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()),
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"public": "app/",
|
||||
"renderSingle": true
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"static": {
|
||||
"public": "app/"
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"symlink": ["app/"]
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"public": "app/",
|
||||
"renderSingle": true
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"public": "app/",
|
||||
"renderSingle": true
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`utilities/cli > render help text 1`] = `
|
||||
"
|
||||
[1m[36mserve[39m[22m - Static file serving and directory listing
|
||||
|
||||
[1mUSAGE[22m
|
||||
|
||||
[1m$[22m [36mserve[39m --help
|
||||
[1m$[22m [36mserve[39m --version
|
||||
[1m$[22m [36mserve[39m folder_name
|
||||
[1m$[22m [36mserve[39m [-l [4mlisten_uri[24m [-l ...]] [[4mdirectory[24m]
|
||||
|
||||
By default, [36mserve[39m will listen on [1m0.0.0.0:3000[22m and serve the
|
||||
current working directory on that address.
|
||||
|
||||
Specifying a single [1m--listen[22m argument will overwrite the default, not supplement it.
|
||||
|
||||
[1mOPTIONS[22m
|
||||
|
||||
--help Shows this help message
|
||||
|
||||
-v, --version Displays the current version of serve
|
||||
|
||||
-l, --listen [4mlisten_uri[24m 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.
|
||||
|
||||
[1mENDPOINTS[22m
|
||||
|
||||
Listen endpoints (specified by the [1m--listen[22m or [1m-l[22m options above) instruct [36mserve[39m
|
||||
to listen on one or more interfaces/ports, UNIX domain sockets, or Windows named pipes.
|
||||
|
||||
For TCP ports on hostname \\"localhost\\":
|
||||
|
||||
[1m$[22m [36mserve[39m -l [4m1234[24m
|
||||
|
||||
For TCP (traditional host/port) endpoints:
|
||||
|
||||
[1m$[22m [36mserve[39m -l tcp://[4mhostname[24m:[4m1234[24m
|
||||
|
||||
For UNIX domain socket endpoints:
|
||||
|
||||
[1m$[22m [36mserve[39m -l unix:[4m/path/to/socket.sock[24m
|
||||
|
||||
For Windows named pipe endpoints:
|
||||
|
||||
[1m$[22m [36mserve[39m -l pipe:\\\\\\\\.\\\\pipe\\\\[4mPipeName[24m
|
||||
"
|
||||
`;
|
|
@ -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,
|
||||
}
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue