hydrogen/packages/cli/src/testing/testing.ts
2021-11-04 15:22:30 -07:00

370 lines
8.6 KiB
TypeScript

import {resolve, dirname, join} from 'path';
import {paramCase} from 'change-case';
import {promisify} from 'util';
import {spawn, exec} from 'child_process';
import {
readFile,
mkdirp,
writeFile,
pathExists,
emptyDir,
remove,
} from 'fs-extra';
import playwright from 'playwright-chromium';
import {createServer as createNodeServer} from 'http';
import {
createServer as createViteServer,
build,
ViteDevServer,
UserConfig,
} from 'vite';
import sirv from 'sirv';
import getPort from 'get-port';
const INPUT_TIMEOUT = 500;
const execPromise = promisify(exec);
type Command = 'create' | 'create component';
type Input = Record<string, string | boolean | null>;
interface App {
withServer: (
runner: (context: ServerContext) => Promise<void>
) => Promise<void>;
}
interface Context {
fs: Sandbox;
run(command: Command, input?: Input): Promise<App>;
}
interface Page {
view(path: string): Promise<void>;
screenshot(path: string): Promise<void>;
textContent: playwright.Page['textContent'];
click: playwright.Page['click'];
}
interface ServerContext {
page: Page;
}
interface Server {
start(directory: string): Promise<string>;
stop(): Promise<void>;
}
interface Options {
debug?: true;
}
interface ServerOptions extends Options {
dev?: true;
}
export enum KeyInput {
Down = '\x1B\x5B\x42',
Up = '\x1B\x5B\x41',
Enter = '\x0D',
Space = '\x20',
No = 'n',
Yes = 'y',
}
const hydrogenCli = resolve(__dirname, '../../', 'bin', 'hydrogen');
const fixtureRoot = resolve(__dirname, '../fixtures');
export async function withCli(
runner: (context: Context) => void,
options?: Options
) {
const name = paramCase(expect.getState().currentTestName);
const directory = join(fixtureRoot, name);
const fs = await createSandbox(directory);
try {
await runner({
fs,
run: async (command: Command, input: Input) => {
const appName =
input && typeof input.name === 'string' ? input.name : 'snow-devil';
const result = await runCliCommand(directory, command, input);
if (options?.debug) {
console.log(result);
}
return {
withServer: async function withServer(
runner: (context: ServerContext) => Promise<void>,
serverOptions?: ServerOptions
) {
const launchOptions = serverOptions?.debug
? {
headless: false,
}
: {};
let count = 1;
const server = await createServer(serverOptions);
const browser = await playwright.chromium.launch(launchOptions);
const context = await browser.newContext();
const playrightPage = await context.newPage();
try {
const appDirectory = join(directory, appName);
await runInstall(appDirectory);
const url = await server?.start(appDirectory);
const page = {
view: async (path: string) => {
const finalUrl = new URL(path, url);
await playrightPage.goto(finalUrl.toString());
},
screenshot: async (suffix?: string) => {
await playrightPage.screenshot({
path: `artifacts/${count++}-${name}${
suffix ? `-${suffix}` : ''
}.png`,
});
},
textContent: (el: string) => playrightPage.textContent(el),
click: (el: string) => playrightPage.click(el),
};
await runner({page});
} finally {
await browser.close();
await context.close();
await playrightPage.close();
await server.stop();
}
},
};
},
});
} catch (error) {
console.log(error);
if (!options?.debug) {
await fs.cleanup();
}
} finally {
if (!options?.debug) {
await fs.cleanup();
}
}
}
async function createSandbox(directory: string) {
await mkdirp(directory);
await writeFile(join(directory, '.gitignore'), '*');
return new Sandbox(directory);
}
async function runCliCommand(
directory: string,
command: Command,
input: Input
): Promise<Result> {
const result = new Result();
const userInput = inputFrom(input, command);
const childProcess = await spawn(hydrogenCli, command.split(' '), {
cwd: directory,
env: {...process.env},
});
return new Promise((resolve, reject) => {
incrementallyPassInputs(userInput);
function onError(err: any) {
childProcess.stdin.end();
result.error = err.data;
reject(result);
}
childProcess.stdout.on('data', (data) => {
result.stdout.push(data.toString());
});
childProcess.on('error', onError);
childProcess.on('close', () => {
result.success = true;
resolve(result);
});
});
function incrementallyPassInputs(inputs: string[]) {
if (inputs.length === 0) {
childProcess.stdin.end();
return;
}
setTimeout(() => {
childProcess.stdin.write(inputs[0]);
incrementallyPassInputs(inputs.slice(1));
}, INPUT_TIMEOUT);
}
}
async function runInstall(directory: string) {
return execPromise('yarn', {
cwd: directory,
env: {...process.env},
});
}
// @ts-ignore
async function createBuild(directory: string) {
const clientOptions: UserConfig = {
root: directory,
build: {
outDir: `dist/client`,
manifest: true,
},
};
const serverOptions: UserConfig = {
root: directory,
build: {
outDir: `dist/server`,
ssr: 'src/entry-server.jsx',
},
};
await Promise.all([build(clientOptions), build(serverOptions)]);
}
async function createServer(options?: ServerOptions): Promise<Server> {
let server: ViteDevServer | null = null;
return {
start: async (directory) => {
server = await createViteServer({
root: directory,
configFile: resolve(directory, 'vite.config.js'),
});
await server.listen();
const base = server.config.base === '/' ? '' : server.config.base;
const url = `http://localhost:${server.config.server.port}${base}`;
return url;
},
async stop() {
if (!server) {
console.log('Attempted to stop the server, but it does not exist.');
return;
}
await server.close();
},
};
}
// @ts-ignore
async function createStaticServer(directory: string): Promise<Server> {
const serve = sirv(resolve(directory, 'dist'));
const httpServer = createNodeServer(serve);
const port = await getPort();
return {
start: () => {
return new Promise((resolve, reject) => {
httpServer.on('error', reject);
httpServer.listen(port, () => {
httpServer.removeListener('error', reject);
resolve(`http://localhost:${port}`);
});
});
},
stop: async () => {
httpServer.close();
},
};
}
class Sandbox {
constructor(public readonly root: string) {}
resolvePath(...parts: string[]) {
return resolve(this.root, ...parts);
}
async write(file: string, contents: string) {
const filePath = this.resolvePath(file);
await mkdirp(dirname(filePath));
await writeFile(filePath, contents, {encoding: 'utf8'});
}
async read(file: string) {
const filePath = this.resolvePath(file);
if (!(await pathExists(filePath))) {
throw new Error(`Tried to read ${filePath}, but it could not be found.`);
}
return readFile(filePath, 'utf8');
}
async exists(file: string) {
const filePath = this.resolvePath(file);
return pathExists(filePath);
}
async cleanup() {
await emptyDir(this.root);
await remove(this.root);
}
}
class Result {
success: boolean = false;
error: Error | null = null;
stderr: string[] = [];
stdout: string[] = [];
constructor() {}
get inspect() {
return {
success: this.success,
error: this.error,
stderr: this.stderr.join(''),
stdout: this.stdout.join(''),
};
}
}
function inputFrom(input: Input, command: Command): string[] {
const length = 10;
const result: string[] = Array.from({length}, () => KeyInput.Enter);
if (input == null) {
return result;
}
Object.values(input).forEach((value, index) => {
const keyStrokes = [];
if (typeof value === 'string') {
keyStrokes.push(value);
}
if (value === false) {
keyStrokes.push(KeyInput.No);
}
if (value === true) {
keyStrokes.push(KeyInput.Yes);
}
result[index] = [...keyStrokes, KeyInput.Enter].join('');
});
return result;
}